diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e1c1eae94..73a717a71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.6', '3.7', '3.8'] + python-version: ['3.7', '3.8'] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 53f5bcc28..c9ab507ca 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,5 @@ pip-wheel-metadata/ # macOS DS_Store .DS_Store +.gitignore +.vscode/settings.json diff --git a/MANIFEST.in b/MANIFEST.in index 156bbd1f6..9ebce7b6f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include README.rst include MANIFEST.in include VERSION +include skfda/py.typed include pyproject.toml include *.txt recursive-include deps * diff --git a/README.rst b/README.rst index 774935851..c641b7691 100644 --- a/README.rst +++ b/README.rst @@ -65,7 +65,6 @@ Requirements * `fdasrsf `_ - SRSF framework * `findiff `_ - Finite differences * `matplotlib `_ - Plotting with Python -* `mpldatacursor `_ - Interactive data cursors for matplotlib * `multimethod `_ - Multiple dispatch * `numpy `_ - The fundamental package for scientific computing with Python * `pandas `_ - Powerful Python data analysis toolkit diff --git a/THANKS.txt b/THANKS.txt index e5d3abfe0..8530edd4b 100644 --- a/THANKS.txt +++ b/THANKS.txt @@ -5,4 +5,10 @@ Carlos Ramos Carreño for the design, reviews and supervision, and for contribut Pablo Marcos Manchón for the registration functions, including integration with fdasrsf. Amanda Hernando Bernabé for visualization and clustering functions. Pablo Pérez Manso for regression and related utilities. -Sergio Ruiz Lozano for the design of the logo. \ No newline at end of file +Yujian Hong for the Principal Component Analysis functionalities. +David García Fernandez for implementing Anova and Hotelling tests. +Pedro Martín Rodríguez-Ponga Eyriès for implementing several classification methods. +Álvaro Sánchez Romero for improving the visualization methods and adding interactive visualizations. +Elena Petrunina for improving the documentation, and regression functions. +Luis Alberto Rodriguez Ramirez for providing mathematical support. +Sergio Ruiz Lozano for the design of the logo. diff --git a/VERSION b/VERSION index 2eb3c4fe4..5a2a5806d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5 +0.6 diff --git a/docs/.gitignore b/docs/.gitignore index 1588679a9..6efaba914 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,4 @@ /auto_examples/ +/auto_tutorial/ /backreferences/ **/autosummary/ \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index e5d3c5645..ad2c23326 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -51,6 +51,7 @@ help: clean: rm -rf $(BUILDDIR)/* rm -rf auto_examples + rm -rf auto_tutorial rm -rf modules/autosummary rm -rf modules/exploratory/visualization/autosummary rm -rf modules/exploratory/autosummary diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index c97621a73..fede4ca4e 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -14,18 +14,9 @@ {%- endfor %} {% endif %} - .. automethod:: __init__ - {% endblock %} - - {% block attributes %} - {% if attributes %} - .. rubric:: Attributes - - .. autosummary:: - {% for item in attributes %} - ~{{ name }}.{{ item }} + {% for item in methods %} + .. automethod:: {{ item }} {%- endfor %} - {% endif %} {% endblock %} .. include:: {{package}}/backreferences/{{fullname}}.examples \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 2be688a8e..a59de0212 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,6 +22,9 @@ import sys import pkg_resources +# -- Extensions to the Napoleon GoogleDocstring class --------------------- +from sphinx.ext.napoleon.docstring import GoogleDocstring + try: release = pkg_resources.get_distribution('scikit-fda').version except pkg_resources.DistributionNotFound: @@ -54,7 +57,10 @@ 'sphinx.ext.intersphinx', 'sphinx.ext.doctest', 'jupyter_sphinx', - 'sphinx.ext.autodoc.typehints'] + 'sphinx.ext.autodoc.typehints', + 'sphinxcontrib.bibtex'] + +bibtex_bibfiles = ['refs.bib'] autodoc_default_flags = ['members', 'inherited-members'] @@ -221,32 +227,66 @@ 'sklearn': ('https://scikit-learn.org/stable', None), 'matplotlib': ('https://matplotlib.org/', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), - 'mpldatacursor': ('https://pypi.org/project/mpldatacursor/', None), } + +tutorial_list = [ + "plot_introduction.py", + "plot_getting_data.py", + "plot_basis_representation.py", + "plot_skfda_sklearn.py", +] + + +class SkfdaExplicitSubOrder(object): + """ + Class for use within the 'within_subsection_order' key. + + Inspired by Matplotlib gallery. + + """ + + def __init__(self, src_dir: str) -> None: + self.src_dir = src_dir # src_dir is unused here + self.ordered_list = tutorial_list + + def __call__(self, filename: str) -> str: + """Return a string determining the sort order.""" + if filename in self.ordered_list: + ind = self.ordered_list.index(filename) + return f"{ind:04d}" + + # ensure not explicitly listed items come last. + return f"zzz{filename}" + + sphinx_gallery_conf = { # path to your examples scripts - 'examples_dirs': '../examples', + 'examples_dirs': ['../examples', '../tutorial'], # path where to save gallery generated examples - 'gallery_dirs': 'auto_examples', + 'gallery_dirs': ['auto_examples', 'auto_tutorial'], 'reference_url': { # The module you locally document uses None 'skfda': None, }, 'backreferences_dir': 'backreferences', 'doc_module': 'skfda', + 'within_subsection_order': SkfdaExplicitSubOrder, } autosummary_generate = True autodoc_typehints = "description" napoleon_use_rtype = True +autodoc_type_aliases = { + "ArrayLike": "ArrayLike", + "GridPointsLike": "Union[ArrayLike, Sequence[ArrayLike]]", +} + # Napoleon fix for attributes # Taken from # https://michaelgoerz.net/notes/extending-sphinx-napoleon-docstring-sections.html -# -- Extensions to the Napoleon GoogleDocstring class --------------------- -from sphinx.ext.napoleon.docstring import GoogleDocstring # first, we define new methods for any new sections and add them to the class diff --git a/docs/glossary.rst b/docs/glossary.rst index 502fb2829..13ccd49dc 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -29,6 +29,12 @@ General Concepts domain The set of possible input values of a function. + domain range + The valid range where a function can be evaluated. It is a Python + sequence that contains, for each dimension of the domain, a tuple with + the minimum and maximum values for that dimension. Usually used in + plotting functions and as the domain of integration for this function. + FDA Functional Data Analysis The branch of statistics that deals with curves, surfaces or other diff --git a/docs/index.rst b/docs/index.rst index 272a0438c..48bcb3d6c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,21 +14,27 @@ or clustering of functional data. In the `project page `_ hosted by Github you can find more information related to the development of the package. +.. toctree:: + :caption: Using scikit-fda + :hidden: + + auto_tutorial/index .. toctree:: - :maxdepth: 2 - :caption: Contents: + :maxdepth: 1 :titlesonly: + :hidden: - apilist - glossary - + auto_examples/index .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :titlesonly: + :hidden: + :caption: More documentation - auto_examples/index + apilist + glossary An exhaustive list of all the contents of the package can be found in the :ref:`genindex`. @@ -58,6 +64,14 @@ In this type of installation make sure that your default Python version is currently supported, or change the python and pip commands by specifying a version, such as python3.6. +How do I start? +--------------- + +If you want a quick overview of the package, we recommend you to try the +new :doc:`tutorial `. For articles about specific +topics, feel free to explore the :doc:`examples `. Want +to check the documentation of a particular class or function? Try searching +for it in the :doc:`API list `. Contributions ------------- diff --git a/docs/modules/exploratory/visualization/clustering.rst b/docs/modules/exploratory/visualization/clustering.rst index 86848f9ae..e8bb23d86 100644 --- a/docs/modules/exploratory/visualization/clustering.rst +++ b/docs/modules/exploratory/visualization/clustering.rst @@ -2,14 +2,14 @@ Clustering Plots ================ In order to show the results of the cluster algorithms in a visual way, :mod:`this module ` is -implemented. It contains the following methods: +implemented. It contains the following classes: .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.clustering.plot_clusters - skfda.exploratory.visualization.clustering.plot_cluster_lines - skfda.exploratory.visualization.clustering.plot_cluster_bars + skfda.exploratory.visualization.clustering.ClusterPlot + skfda.exploratory.visualization.clustering.ClusterMembershipLinesPlot + skfda.exploratory.visualization.clustering.ClusterMembershipPlot In the first one, the samples of the FDataGrid are divided by clusters which are assigned different colors. The following functions, are only valid for the diff --git a/docs/modules/exploratory/visualization/fpca.rst b/docs/modules/exploratory/visualization/fpca.rst index 8f22e884e..141769ef4 100644 --- a/docs/modules/exploratory/visualization/fpca.rst +++ b/docs/modules/exploratory/visualization/fpca.rst @@ -1,12 +1,12 @@ Functional Principal Component Analysis plots ============================================= In order to show the modes of variation that the principal components represent, -the following function is implemented +the following class is implemented .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.fpca.plot_fpca_perturbation_graphs + skfda.exploratory.visualization.fpca.FPCAPlot See the example :ref:`sphx_glr_auto_examples_plot_fpca.py` for detailed explanation. diff --git a/docs/modules/misc/metrics.rst b/docs/modules/misc/metrics.rst index a7ac7c7e7..9cb424244 100644 --- a/docs/modules/misc/metrics.rst +++ b/docs/modules/misc/metrics.rst @@ -7,15 +7,33 @@ This module contains multiple functional distances and norms. Lp Spaces --------- -The following functions computes the norms and distances used in Lp spaces. +The following classes compute the norms and metrics used in Lp spaces. One +first has to create an instance for the class, specifying the desired value +for ``p``, and use this instance to evaluate the norm or distance over +:term:`functional data objects`. .. autosummary:: :toctree: autosummary - skfda.misc.metrics.lp_norm - skfda.misc.metrics.lp_distance + skfda.misc.metrics.LpNorm + skfda.misc.metrics.LpDistance + +As the :math:`L_1`, :math:`L_2` and :math:`L_{\infty}` norms are very common +in :term:`FDA`, instances for these have been created, called respectively +``l1_norm``, ``l2_norm`` and ``linf_norm``. The same is true for metrics, +having ``l1_distance``, ``l2_distance`` and ``linf_distance`` already +created. +The following functions are wrappers for convenience, in case that one +only wants to evaluate the norm/metric for a value of ``p``. These functions +cannot be used in objects or methods that require a norm or metric, as the +value of ``p`` must be explicitly passed in each call. +.. autosummary:: + :toctree: autosummary + + skfda.misc.metrics.lp_norm + skfda.misc.metrics.lp_distance Elastic distances ----------------- @@ -32,11 +50,28 @@ analysis and registration of functional data. skfda.misc.metrics.warping_distance -Utils ------ +Metric induced by a norm +------------------------ + +If a norm has been defined, it is possible to construct a metric between two +elements simply subtracting one from the other and computing the norm of the +result. Such a metric is called the metric induced by the norm, and the +:math:`Lp` distance is an example of these. The following class can be used +to construct a metric from a norm in this way: + +.. autosummary:: + :toctree: autosummary + + skfda.misc.metrics.NormInducedMetric + + +Pairwise metric +--------------- + +Some tasks require the computation of all possible distances between pairs +of objets. The following class can compute that efficiently: .. autosummary:: :toctree: autosummary - skfda.misc.metrics.distance_from_norm - skfda.misc.metrics.pairwise_distance + skfda.misc.metrics.PairwiseMetric diff --git a/docs/modules/ml/classification.rst b/docs/modules/ml/classification.rst index e4c2d0a77..d87071a81 100644 --- a/docs/modules/ml/classification.rst +++ b/docs/modules/ml/classification.rst @@ -22,3 +22,7 @@ it is explained the basic usage of these estimators. skfda.ml.classification.KNeighborsClassifier skfda.ml.classification.RadiusNeighborsClassifier skfda.ml.classification.NearestCentroid + skfda.ml.classification.DTMClassifier + skfda.ml.classification.DDClassifier + skfda.ml.classification.DDGClassifier + skfda.ml.classification.MaximumDepthClassifier diff --git a/docs/modules/ml/clustering.rst b/docs/modules/ml/clustering.rst index 3bdb59647..c6099bfdb 100644 --- a/docs/modules/ml/clustering.rst +++ b/docs/modules/ml/clustering.rst @@ -34,3 +34,18 @@ searches. :toctree: autosummary skfda.ml.clustering.NearestNeighbors + +Hierarchical clustering +----------------------- + +Hierarchical clusterings are constructed by iteratively merging or splitting +clusters given a metric between their elements, in order to cluster together +elements that are close from each other. This is repeated until a desired +number of clusters is obtained. The resulting hierarchy of clusters can be +represented as a tree, called a dendogram. The following hierarchical +clusterings are supported: + +.. autosummary:: + :toctree: autosummary + + skfda.ml.clustering.AgglomerativeClustering diff --git a/docs/modules/ml/regression.rst b/docs/modules/ml/regression.rst index ce416a58a..ea582013a 100644 --- a/docs/modules/ml/regression.rst +++ b/docs/modules/ml/regression.rst @@ -10,12 +10,14 @@ Linear regression A linear regression model is one in which the response variable can be expressed as a linear combination of the covariates (which could be -multivariate or functional). +multivariate or functional). The following linear models are available +in scikit-fda: .. autosummary:: :toctree: autosummary skfda.ml.regression.LinearRegression + skfda.ml.regression.HistoricalLinearRegression Nearest Neighbors ----------------- diff --git a/docs/modules/representation.rst b/docs/modules/representation.rst index 83efe532a..16e0a75d1 100644 --- a/docs/modules/representation.rst +++ b/docs/modules/representation.rst @@ -56,14 +56,14 @@ The following classes are used to define different basis for skfda.representation.basis.Monomial skfda.representation.basis.Constant -The following class, allows the construction of a basis for -:math:`\mathbb{R}^n \to \mathbb{R}` functions from -several :math:`\mathbb{R} \to \mathbb{R}` bases. +The following classes, allow the construction of a basis for +:math:`\mathbb{R}^n \to \mathbb{R}` functions. .. autosummary:: :toctree: autosummary skfda.representation.basis.Tensor + skfda.representation.basis.FiniteElement The following class, allows the construction of a basis for :math:`\mathbb{R}^n \to \mathbb{R}^m` functions from @@ -73,6 +73,16 @@ several :math:`\mathbb{R}^n \to \mathbb{R}` bases. :toctree: autosummary skfda.representation.basis.VectorValued + +All the aforementioned basis inherit the basics from an +abstract base class :class:`Basis`. Users can create their own +basis subclassing this class and implementing the required +methods. + +.. autosummary:: + :toctree: autosummary + + skfda.representation.basis.Basis Generic representation ---------------------- diff --git a/docs/refs.bib b/docs/refs.bib new file mode 100644 index 000000000..50e64219d --- /dev/null +++ b/docs/refs.bib @@ -0,0 +1,376 @@ +@article{berrendero+cuevas+torrecilla_2016_hunting, + author = {Berrendero, J.R. and Cuevas, Antonio and Torrecilla, José}, + year = {2016}, + pages = {619 -- 638}, + title = {Variable selection in functional data classification: A maxima-hunting proposal}, + number = {2}, + volume = {26}, + journal = {Statistica Sinica}, + doi = {10.5705/ss.202014.0014} +} + +@article{berrendero+cuevas+torrecilla_2018_hilbert, + author = {José R. Berrendero and Antonio Cuevas and José L. Torrecilla}, + title = {On the Use of Reproducing Kernel Hilbert Spaces in Functional Classification}, + journal = {Journal of the American Statistical Association}, + volume = {113}, + number = {523}, + pages = {1210 -- 1218}, + year = {2018}, + publisher = {Taylor & Francis}, + doi = {10.1080/01621459.2017.1320287}, + URL = {https://doi.org/10.1080/01621459.2017.1320287} +} + +@inproceedings{breunig++_2000_outliers, + author = {Breunig, Markus and Kriegel, Hans-Peter and Ng, Raymond and Sander, Joerg}, + year = {2000}, + month = {06}, + pages = {93 -- 104}, + title = {LOF: Identifying Density-Based Local Outliers.}, + volume = {29}, + journal = {ACM Sigmod Record}, + doi = {10.1145/342009.335388} +} + +@article{cuesta-albertos++_2015_ddg, + title = {The DDG-classifier in the functional setting}, + author = {J. A. Cuesta-Albertos and M. Febrero-Bande and M. Oviedo de la Fuente}, + journal = {TEST}, + year = {2015}, + volume = {26}, + pages = {119 -- 142} +} + +@article{cuevas++_2004_anova + author = {Cuevas, Antonio and Febrero-Bande, Manuel and Fraiman, Ricardo}, + year = {2004}, + month = {02}, + pages = {111 -- 122}, + title = {An ANOVA test for functional data}, + volume = {47}, + journal = {Computational Statistics & Data Analysis}, + doi = {10.1016/j.csda.2003.10.021} +} + +@article{dai+genton_2018_visualization, + author = {Wenlin Dai and Marc G. Genton}, + title = {Multivariate Functional Data Visualization and Outlier Detection}, + journal = {Journal of Computational and Graphical Statistics}, + volume = {27}, + number = {4}, + pages = {923 -- 934}, + year = {2018}, + publisher = {Taylor & Francis}, + doi = {10.1080/10618600.2018.1473781}, + URL = {https://doi.org/10.1080/10618600.2018.1473781} +} + +@inbook{ferraty+vieu_2006_nonparametric_knn, + author = {Frédéric Ferraty and Philippe Vieu}, + title = {Nonparametric Functional Data Analysis. Theory and Practice}, + chapter = {Functional Nonparametric Supervised Classification}, + pages = {116}, + publisher = {Springer-Verlag New York}, + year = {2006}, + isbn = {978-0-387-30369-7}, + doi = {10.1007/0-387-36620-2} +} + +@article{fraiman+muniz_2001_trimmed, + author = {Fraiman, Ricardo and Muniz, Graciela}, + year = {2001}, + month = {02}, + pages = {419 -- 440}, + title = {Trimmed means for functional data}, + volume = {10}, + journal = {TEST: An Official Journal of the Spanish Society of Statistics and Operations Research}, + doi = {10.1007/BF02595706} +} + +@article{gervini_2008_estimation, + author = {Gervini, Daniel}, + title = "{Robust functional estimation using the median and spherical principal components}", + journal = {Biometrika}, + volume = {95}, + number = {3}, + pages = {587 -- 600}, + year = {2008}, + month = {09}, + issn = {0006-3444}, + doi = {10.1093/biomet/asn031}, + url = {https://doi.org/10.1093/biomet/asn031} +} + +@article{ghosh+chaudhuri_2005_depth, + author = {Ghosh, Anil and Chaudhuri, Probal}, + year = {2005}, + month = {02}, + pages = {327 -- 350}, + title = {On Maximum Depth and Related Classifiers}, + volume = {32}, + journal = {Scandinavian Journal of Statistics}, + doi = {10.1111/j.1467-9469.2005.00423.x} +} + +@article{pini+stamm+vantini_2018_hotellings, + title = {Hotelling's T2 in separable Hilbert spaces}, + author = {Alessia Pini and Aymeric Stamm and Simone Vantini}, + journal = {Journal of Multivariate Analysis}, + year = {2018}, + month = {05}, + volume = {167}, + pages = {284 -- 305}, + doi = {10.1016/j.jmva.2018.05.007} +} + +@inbook{ramsay+silverman_2005_functional_bspline, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {From functional data to smooth functions}, + pages = {50 -- 51}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_spline, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {Smoothing functional data with a roughness penalty}, + pages = {86 -- 87}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_spline_squares, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {Smoothing functional data with a roughness penalty}, + pages = {89 -- 90}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_shift, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {The registration and display of functional data}, + pages = {129 -- 132}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_landmark, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {The registration and display of functional data}, + pages = {132 -- 136}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_newton-raphson, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {The registration and display of functional data}, + pages = {142 -- 144}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_discretizing, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {Principal components analysis for functional data}, + pages = {161}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{ramsay+silverman_2005_functional_basis, + author = {James Ramsay and B. W. Silverman}, + title = {Functional Data Analysis}, + chapter = {Principal components analysis for functional data}, + pages = {161 -- 164}, + publisher = {Springer-Verlag New York}, + year = {2005}, + isbn = {978-0-387-40080-8}, + doi = {110.1007/b98888} +} + +@inbook{srivastava+klassen_2016_analysis_elastic, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Functional Data and Elastic Registration}, + pages = {73 -- 122}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_square, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Functional Data and Elastic Registration}, + pages = {91 -- 93}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_amplitude, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Functional Data and Elastic Registration}, + pages = {107 -- 109}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_phase, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Functional Data and Elastic Registration}, + pages = {109 -- 111}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_probability, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Functional Data and Elastic Registration}, + pages = {113 -- 117}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_karcher, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Statistical Modeling of Functional Data}, + pages = {273 -- 274}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@inbook{srivastava+klassen_2016_analysis_orbit, + author = {Srivastava, Anuj and Klassen, Eric}, + title = {Functional and Shape Data Analysis}, + chapter = {Statistical Modeling of Functional Data}, + pages = {274 -- 277}, + publisher = {Springer-Verlag New York}, + year = {2016}, + isbn = {978-1-4939-4018-9}, + doi = {10.1007/978-1-4939-4020-2} +} + +@article{srivastava++_2011_ficher-rao, + author = {Srivastava, Anuj and Wu, Wei and Kurtek, Sebastian and Klassen, Eric and Marron, J.}, + year = {2011}, + journal={}, + title = {Registration of Functional Data Using Fisher-Rao Metric}, + pages = {5 -- 7}, + URL = {https://arxiv.org/abs/1103.3817v2} +} + +@article{srivastava++_2011_ficher-rao_karcher, + author = {Srivastava, Anuj and Wu, Wei and Kurtek, Sebastian and Klassen, Eric and Marron, J.}, + year = {2011}, + journal={}, + title = {Registration of Functional Data Using Fisher-Rao Metric}, + pages = {7 -- 10}, + URL = {https://arxiv.org/abs/1103.3817v2} +} + +@article{srivastava++_2011_ficher-rao_orbit, + author = {Srivastava, Anuj and Wu, Wei and Kurtek, Sebastian and Klassen, Eric and Marron, J.}, + year = {2011}, + journal={}, + title = {Registration of Functional Data Using Fisher-Rao Metric}, + pages = {9 -- 10}, + URL = {https://arxiv.org/abs/1103.3817v2} +} + +@article{sun+genton_2011_boxplots, + author = {Ying Sun and Marc G. Genton}, + title = {Functional Boxplots}, + journal = {Journal of Computational and Graphical Statistics}, + volume = {20}, + number = {2}, + pages = {316 -- 334}, + year = {2011}, + publisher = {Taylor & Francis}, + doi = {10.1198/jcgs.2011.09224}, + URL = {https://doi.org/10.1198/jcgs.2011.09224} +} + +@article{szekely+rizzo_2010_brownian, + author = {Gábor J. Székely and Maria L. Rizzo}, + title = {Brownian distance covariance}, + volume = {3}, + journal = {The Annals of Applied Statistics}, + number = {4}, + publisher = {Institute of Mathematical Statistics}, + pages = {1236 -- 1265}, + year = {2009}, + doi = {10.1214/09-AOAS312}, + URL = {https://doi.org/10.1214/09-AOAS312} +} + +@inproceedings{torrecilla+suarez_2016_hunting, + author = {Torrecilla, Jose L. and Su\'{a}rez, Alberto}, + title = {Feature Selection in Functional Data Classification with Recursive Maxima Hunting}, + year = {2016}, + volume = {29}, + publisher = {Curran Associates Inc.}, + booktitle = {Proceedings of the 30th International Conference on Neural Information Processing Systems}, + pages = {4835 -- 4843}, + series = {NIPS'16} +} + +@inbook{wasserman_2006_nonparametric_nw, + author = {Larry Wasserman}, + title = {All of Nonparametric Statistics}, + chapter = {Nonparametric Regression}, + pages = {71}, + publisher = {Springer-Verlag New York}, + year = {2006}, + isbn = {978-0-387-25145-5}, + doi = {10.1007/0-387-30623-4} +} + +@inbook{wasserman_2006_nonparametric_llr, + author = {Larry Wasserman}, + title = {All of Nonparametric Statistics}, + chapter = {Nonparametric Regression}, + pages = {77}, + publisher = {Springer-Verlag New York}, + year = {2006}, + isbn = {978-0-387-25145-5}, + doi = {10.1007/0-387-30623-4} +} \ No newline at end of file diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index 7e5832f1b..c94d3b17a 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -106,7 +106,7 @@ # :func:`~skfda.exploratory.depth.IntegratedDepth` is used and the 25% and # 75% central regions are specified. -fdBoxplot = Boxplot(fd_temperatures, depth_method=IntegratedDepth(), +fdBoxplot = Boxplot(fd_temperatures, depth_method=IntegratedDepth(), prob=[0.75, 0.5, 0.25]) fdBoxplot.plot() diff --git a/examples/plot_clustering.py b/examples/plot_clustering.py index f83ca5323..e4c968f11 100644 --- a/examples/plot_clustering.py +++ b/examples/plot_clustering.py @@ -12,14 +12,16 @@ # sphinx_gallery_thumbnail_number = 6 -from skfda import datasets -from skfda.exploratory.visualization.clustering import ( - plot_clusters, plot_cluster_lines, plot_cluster_bars) -from skfda.ml.clustering import KMeans, FuzzyCMeans - import matplotlib.pyplot as plt import numpy as np +from skfda import datasets +from skfda.exploratory.visualization.clustering import ( + ClusterMembershipLinesPlot, + ClusterMembershipPlot, + ClusterPlot, +) +from skfda.ml.clustering import FuzzyCMeans, KMeans ############################################################################## # First, the Canadian Weather dataset is downloaded from the package 'fda' in @@ -81,8 +83,8 @@ cluster_colors = climate_colors[np.array([0, 2, 1])] cluster_labels = climates.categories[np.array([0, 2, 1])] -plot_clusters(kmeans, fd, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterPlot(kmeans, fd, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() ############################################################################## # Other clustering algorithm implemented is the Fuzzy K-Means found in the @@ -90,15 +92,15 @@ # above procedure, an object of this type is instantiated with the desired # data and then, the # :func:`~skfda.ml.clustering.FuzzyCMeans.fit` method is called. -# Internally, the attribute ``labels_`` is calculated, which contains +# Internally, the attribute ``membership_degree_`` is calculated, which contains # ´n_clusters´ elements for each sample and dimension, denoting the degree of # membership of each sample to each cluster. They are obtained calling the -# method :func:`~skfda.ml.clustering.FuzzyCMeans.predict`. Also, the centroids +# method :func:`~skfda.ml.clustering.FuzzyCMeans.predict_proba`. Also, the centroids # of each cluster are obtained. fuzzy_kmeans = FuzzyCMeans(n_clusters=n_clusters, random_state=seed) fuzzy_kmeans.fit(fd) -print(fuzzy_kmeans.predict(fd)) +print(fuzzy_kmeans.predict_proba(fd)) ############################################################################## # To see the information in a graphic way, the method @@ -106,8 +108,8 @@ # be used. It assigns each sample to the cluster whose membership value is the # greatest. -plot_clusters(fuzzy_kmeans, fd, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterPlot(fuzzy_kmeans, fd, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() ############################################################################## # Another plot implemented to show the results in the class @@ -119,8 +121,8 @@ colors_by_climate = colormap(climates.codes / (n_climates - 1)) -plot_cluster_lines(fuzzy_kmeans, fd, cluster_labels=cluster_labels, - sample_colors=colors_by_climate) +ClusterMembershipLinesPlot(fuzzy_kmeans, fd, cluster_labels=cluster_labels, + sample_colors=colors_by_climate).plot() ############################################################################## # Finally, the function @@ -128,8 +130,8 @@ # returns a barplot. Each sample is designated with a bar which is filled # proportionally to the membership values with the color of each cluster. -plot_cluster_bars(fuzzy_kmeans, fd, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterMembershipPlot(fuzzy_kmeans, fd, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() ############################################################################## # The possibility of sorting the bars according to a cluster is given @@ -137,15 +139,15 @@ # [0, n_clusters). # # We can order the data using the first cluster: -plot_cluster_bars(fuzzy_kmeans, fd, sort=0, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterMembershipPlot(fuzzy_kmeans, fd, sort=0, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() ############################################################################## # Using the second cluster: -plot_cluster_bars(fuzzy_kmeans, fd, sort=1, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterMembershipPlot(fuzzy_kmeans, fd, sort=1, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() ############################################################################## # And using the third cluster: -plot_cluster_bars(fuzzy_kmeans, fd, sort=2, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) +ClusterMembershipPlot(fuzzy_kmeans, fd, sort=2, cluster_colors=cluster_colors, + cluster_labels=cluster_labels).plot() diff --git a/examples/plot_elastic_registration.py b/examples/plot_elastic_registration.py index c49be791f..e577eae37 100644 --- a/examples/plot_elastic_registration.py +++ b/examples/plot_elastic_registration.py @@ -10,14 +10,13 @@ # sphinx_gallery_thumbnail_number = 5 +import numpy as np + import skfda -from skfda.datasets import make_multimodal_samples, fetch_growth +from skfda.datasets import fetch_growth, make_multimodal_samples from skfda.preprocessing.registration import ElasticRegistration from skfda.preprocessing.registration.elastic import elastic_mean -import numpy as np - - ############################################################################## # In the example of pairwise alignment was shown the usage of # :class:`~skfda.preprocessing.registration.ElasticRegistration` to align @@ -31,7 +30,7 @@ # # We will create a synthetic dataset to show the basic usage of the # registration. -# + fd = make_multimodal_samples(n_modes=2, stop=4, random_state=1) fd.plot() @@ -78,15 +77,16 @@ # Obtain velocity curves fd.interpolation = skfda.representation.interpolation.SplineInterpolation(3) -fd = fd.to_grid(np.linspace(*fd.domain_range[0], 200)).derivative() -fd = fd.to_grid(np.linspace(*fd.domain_range[0], 50)) -fd.plot() +fd_derivative = fd.to_grid(np.linspace(*fd.domain_range[0], 200)).derivative() +fd_derivative = fd_derivative.to_grid(np.linspace(*fd.domain_range[0], 50)) +fd_derivative.dataset_name = f"{fd.dataset_name} - derivative" +fd_derivative.plot() ############################################################################## # We now show the aligned curves: -fd_align = elastic_registration.fit_transform(fd) -fd_align.dataset_name += " - aligned" +fd_align = elastic_registration.fit_transform(fd_derivative) +fd_align.dataset_name = f"{fd.dataset_name} - derivative aligned" fd_align.plot() @@ -94,10 +94,10 @@ # * Srivastava, Anuj & Klassen, Eric P. (2016). Functional and shape data # analysis. In *Functional Data and Elastic Registration* (pp. 73-122). # Springer. -# +# # * Tucker, J. D., Wu, W. and Srivastava, A. (2013). Generative Models for -# Functional Data using Phase and Amplitude Separation. Computational Statistics -# and Data Analysis, Vol. 61, 50-66. +# Functional Data using Phase and Amplitude Separation. +# Computational Statistics and Data Analysis, Vol. 61, 50-66. # # * J. S. Marron, James O. Ramsay, Laura M. Sangalli and Anuj Srivastava # (2015). Functional Data Analysis of Amplitude and Phase Variation. diff --git a/examples/plot_explore.py b/examples/plot_explore.py index dbc6b9a94..59a438c56 100644 --- a/examples/plot_explore.py +++ b/examples/plot_explore.py @@ -9,10 +9,9 @@ # Author: Miguel Carbajo Berrocal # License: MIT -import skfda - import numpy as np +import skfda ############################################################################## # In this example we are going to explore the functional properties of the @@ -33,11 +32,18 @@ low_fat = fat < 20 labels = np.full(fd.n_samples, 'high fat') labels[low_fat] = 'low fat' -colors = {'high fat': 'red', - 'low fat': 'blue'} - -fig = fd.plot(group=labels, group_colors=colors, - linewidth=0.5, alpha=0.7, legend=True) +colors = { + 'high fat': 'red', + 'low fat': 'blue', +} + +fig = fd.plot( + group=labels, + group_colors=colors, + linewidth=0.5, + alpha=0.7, + legend=True, +) ############################################################################## # The means of each group are the following ones. @@ -47,9 +53,13 @@ means = mean_high.concatenate(mean_low) -means.dataset_name = fd.dataset_name + ' - means' -means.plot(group=['high fat', 'low fat'], group_colors=colors, - linewidth=0.5, legend=True) +means.dataset_name = f"{fd.dataset_name} - means" +means.plot( + group=['high fat', 'low fat'], + group_colors=colors, + linewidth=0.5, + legend=True, +) ############################################################################## # In this dataset, the vertical shift in the original trajectories is not @@ -60,11 +70,23 @@ # The first derivative is shown below: fdd = fd.derivative() -fig = fdd.plot(group=labels, group_colors=colors, - linewidth=0.5, alpha=0.7, legend=True) +fdd.dataset_name = f"{fd.dataset_name} - derivative" +fig = fdd.plot( + group=labels, + group_colors=colors, + linewidth=0.5, + alpha=0.7, + legend=True, +) ############################################################################## # We now show the second derivative: fdd = fd.derivative(order=2) -fig = fdd.plot(group=labels, group_colors=colors, - linewidth=0.5, alpha=0.7, legend=True) +fdd.dataset_name = f"{fd.dataset_name} - second derivative" +fig = fdd.plot( + group=labels, + group_colors=colors, + linewidth=0.5, + alpha=0.7, + legend=True, +) diff --git a/examples/plot_fpca.py b/examples/plot_fpca.py index 460a1db7c..f3f78bfdd 100644 --- a/examples/plot_fpca.py +++ b/examples/plot_fpca.py @@ -8,19 +8,17 @@ # Author: Yujian Hong # License: MIT +import matplotlib.pyplot as plt + import skfda from skfda.datasets import fetch_growth -from skfda.exploratory.visualization import plot_fpca_perturbation_graphs -from skfda.preprocessing.dim_reduction.projection import FPCA +from skfda.exploratory.visualization import FPCAPlot +from skfda.preprocessing.dim_reduction.feature_extraction import FPCA from skfda.representation.basis import BSpline, Fourier, Monomial -import matplotlib.pyplot as plt -import numpy as np - - ############################################################################## -# In this example we are going to use functional principal component analysis to -# explore datasets and obtain conclusions about said dataset using this +# In this example we are going to use functional principal component analysis +# to explore datasets and obtain conclusions about said dataset using this # technique. # # First we are going to fetch the Berkeley Growth Study data. This dataset @@ -75,10 +73,13 @@ # faster at an early age and boys tend to start puberty later, therefore, their # growth is more significant later. Girls also stop growing early -plot_fpca_perturbation_graphs(basis_fd.mean(), - fpca.components_, - 30, - fig=plt.figure(figsize=(6, 2 * 4))) +FPCAPlot( + basis_fd.mean(), + fpca.components_, + 30, + fig=plt.figure(figsize=(6, 2 * 4)), + n_rows=2, +).plot() ############################################################################## # We can also specify another basis for the principal components as argument diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index 7f661e89d..bda9d1689 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -35,37 +35,82 @@ ############################################################################## # Here we show the general cross validation scores for different values of the -# parameters given to the different smoothing methods. +# parameters given to the different smoothing methods. Currently we have +# three kernel smoothing methods implemented: Nadaraya Watson, Local Linear +# Regression and K Nearest Neighbors (k-NN) -param_values_knn = np.arange(1, 24, 2) -param_values_others = param_values_knn / 32 +############################################################################## +# The smoothing parameter for k-NN is the number of neighbors. We will choose +# this parameter between 1 and 23 in this example. + +n_neighbors = np.arange(1, 24) + +############################################################################## +# The smoothing parameter for Nadaraya Watson and Local Linear Regression is +# a bandwidth parameter, with the same units as the domain of the function. +# As we want to compare the results of these smoothers with k-NN, with uses +# as the smoothing parameter the number of neighbors, we want to use a +# comparable range of values. In this case, we know that our grid points are +# equispaced, so a given bandwidth ``B`` will include +# ``B * N / D`` grid points, where ``N`` is the total number of grid points +# and ``D`` the size of the whole domain range. Thus, if we pick +# ``B = n_neighbors * D / N``, ``B`` will include ``n_neighbors`` grid points +# and we could compare the results of the different smoothers. + +scale_factor = ( + (fd.domain_range[0][1] - fd.domain_range[0][0]) + / len(fd.grid_points[0]) +) + +bandwidth = n_neighbors * scale_factor + +# K-nearest neighbours kernel smoothing. +knn = val.SmoothingParameterSearch( + ks.KNeighborsSmoother(), + n_neighbors, +) +knn.fit(fd) +knn_fd = knn.transform(fd) # Local linear regression kernel smoothing. llr = val.SmoothingParameterSearch( - ks.LocalLinearRegressionSmoother(), param_values_others) + ks.LocalLinearRegressionSmoother(), + bandwidth, +) llr.fit(fd) llr_fd = llr.transform(fd) # Nadaraya-Watson kernel smoothing. nw = val.SmoothingParameterSearch( - ks.NadarayaWatsonSmoother(), param_values_others) + ks.NadarayaWatsonSmoother(), + bandwidth, +) nw.fit(fd) nw_fd = nw.transform(fd) -# K-nearest neighbours kernel smoothing. -knn = val.SmoothingParameterSearch( - ks.KNeighborsSmoother(), param_values_knn) -knn.fit(fd) -knn_fd = knn.transform(fd) +############################################################################## +# The plot of the mean test scores for all smoothers is shown below. +# As the X axis we will use the neighbors for all the smoothers in order +# to compare k-NN with the others, but remember that the bandwidth is +# this quantity scaled by ``scale_factor``! fig = plt.figure() ax = fig.add_subplot(1, 1, 1) -ax.plot(param_values_knn, knn.cv_results_['mean_test_score'], - label='k-nearest neighbors') -ax.plot(param_values_knn, llr.cv_results_['mean_test_score'], - label='local linear regression') -ax.plot(param_values_knn, nw.cv_results_['mean_test_score'], - label='Nadaraya-Watson') +ax.plot( + n_neighbors, + knn.cv_results_['mean_test_score'], + label='k-nearest neighbors', +) +ax.plot( + n_neighbors, + llr.cv_results_['mean_test_score'], + label='local linear regression', +) +ax.plot( + n_neighbors, + nw.cv_results_['mean_test_score'], + label='Nadaraya-Watson', +) ax.legend() fig @@ -84,10 +129,15 @@ knn_fd[10].plot(fig=fig) llr_fd[10].plot(fig=fig) nw_fd[10].plot(fig=fig) -ax.legend(['original data', 'k-nearest neighbors', - 'local linear regression', - 'Nadaraya-Watson'], - title='Smoothing method') +ax.legend( + [ + 'original data', + 'k-nearest neighbors', + 'local linear regression', + 'Nadaraya-Watson', + ], + title='Smoothing method', +) fig ############################################################################## @@ -117,9 +167,11 @@ # the following plots. fd_us = ks.NadarayaWatsonSmoother( - smoothing_parameter=2 / 32).fit_transform(fd[10]) + smoothing_parameter=2 * scale_factor, +).fit_transform(fd[10]) fd_os = ks.NadarayaWatsonSmoother( - smoothing_parameter=15 / 32).fit_transform(fd[10]) + smoothing_parameter=15 * scale_factor, +).fit_transform(fd[10]) ############################################################################## # Under-smoothed diff --git a/examples/plot_landmark_registration.py b/examples/plot_landmark_registration.py index 2555d1922..dbcfc7d51 100644 --- a/examples/plot_landmark_registration.py +++ b/examples/plot_landmark_registration.py @@ -10,8 +10,8 @@ import matplotlib.pyplot as plt import numpy as np -import skfda +import skfda ############################################################################## # The simplest curve alignment procedure is landmark registration. This @@ -28,9 +28,14 @@ # We will use a dataset synthetically generated by # :func:`~skfda.datasets.make_multimodal_samples`, which in this case will # be used to generate bimodal curves. -# -fd = skfda.datasets.make_multimodal_samples(n_samples=4, n_modes=2, std=.002, - mode_std=.005, random_state=1) + +fd = skfda.datasets.make_multimodal_samples( + n_samples=4, + n_modes=2, + std=0.002, + mode_std=0.005, + random_state=1, +) fd.plot() ############################################################################## @@ -45,11 +50,13 @@ # # In general it will be necessary to use numerical or other methods to # determine the location of the landmarks. -# -landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=4, n_modes=2, - std=.002, random_state=1 - ).squeeze() +landmarks = skfda.datasets.make_multimodal_landmarks( + n_samples=4, + n_modes=2, + std=0.002, + random_state=1, +).squeeze() print(landmarks) @@ -71,10 +78,12 @@ # the example of interpolation for more details). # # In this case we will place the landmarks at -0.5 and 0.5. -# warping = skfda.preprocessing.registration.landmark_registration_warping( - fd, landmarks, location=[-0.5, 0.5]) + fd, + landmarks, + location=[-0.5, 0.5], +) # Plots warping fig = warping.plot() @@ -107,7 +116,9 @@ # fd_registered = skfda.preprocessing.registration.landmark_registration( - fd, landmarks) + fd, + landmarks, +) fd_registered.plot() plt.scatter(np.mean(landmarks, axis=0), [1, 1]) @@ -119,5 +130,5 @@ # .. [RaSi2005] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. # Springer. # -# .. [RaHoGr2009] Ramsay, J., Hooker, G. & Graves S. (2009). Functional Data Analysis -# with R and Matlab. Springer. +# .. [RaHoGr2009] Ramsay, J., Hooker, G. & Graves S. (2009). Functional Data +# Analysis with R and Matlab. Springer. diff --git a/examples/plot_landmark_shift.py b/examples/plot_landmark_shift.py index bc1d47f84..ec1722581 100644 --- a/examples/plot_landmark_shift.py +++ b/examples/plot_landmark_shift.py @@ -14,8 +14,8 @@ import matplotlib.pyplot as plt import numpy as np -import skfda +import skfda ############################################################################## # We will use an example dataset synthetically generated by @@ -23,7 +23,7 @@ # used to generate gaussian-like samples with a mode near to 0. # Each sample will be shifted to align their modes to a reference point using # the function :func:`~skfda.preprocessing.registration.landmark_shift`. -# + fd = skfda.datasets.make_multimodal_samples(random_state=1) fd.extrapolation = 'bounds' #  See extrapolation for a detailed explanation. @@ -46,7 +46,6 @@ # # In general it will be necessary to use numerical or other methods to # determine the location of the landmarks. -# landmarks = skfda.datasets.make_multimodal_landmarks(random_state=1).squeeze() @@ -57,17 +56,18 @@ ############################################################################## # Location of the landmarks: -# print(landmarks) ############################################################################## # The following figure shows the result of shifting the curves to align their # landmarks at 0. -# fd_registered = skfda.preprocessing.registration.landmark_shift( - fd, landmarks, location=0) + fd, + landmarks, + location=0, +) fig = fd_registered.plot() fig.axes[0].scatter(0, 1) @@ -79,15 +79,19 @@ # # If the location of the new reference point is not specified it is choosen # the point that minimizes the maximum amount of shift. -# # Curves aligned restricting the domain fd_restricted = skfda.preprocessing.registration.landmark_shift( - fd, landmarks, restrict_domain=True) + fd, + landmarks, + restrict_domain=True, +) # Curves aligned to default point without restrict domain fd_extrapolated = skfda.preprocessing.registration.landmark_shift( - fd, landmarks) + fd, + landmarks, +) fig = fd_extrapolated.plot(linestyle='dashed', label='Extrapolated samples') @@ -98,26 +102,30 @@ # without limitation of the domain or image dimension. As an example we are # going to create a datset with surfaces, in a similar way to the previous # case. -# -fd = skfda.datasets.make_multimodal_samples(n_samples=3, points_per_dim=30, - dim_domain=2, random_state=1) +fd = skfda.datasets.make_multimodal_samples( + n_samples=3, + points_per_dim=30, + dim_domain=2, + random_state=1, +) fd.plot() ############################################################################## # In this case the landmarks will be defined by tuples with 2 coordinates. -# landmarks = skfda.datasets.make_multimodal_landmarks( - n_samples=3, dim_domain=2, random_state=1).squeeze() + n_samples=3, + dim_domain=2, + random_state=1, +).squeeze() print(landmarks) ############################################################################## # As in the previous case, we can align the curves to a specific point, # or by default will be chosen the point that minimizes the maximum amount # of displacement. -# fd_registered = skfda.preprocessing.registration.landmark_shift(fd, landmarks) @@ -126,6 +134,5 @@ plt.show() ############################################################################### -# .. [RaSi2005-2] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. -# Springer. -# +# .. [RaSi2005-2] Ramsay, J., Silverman, B. W. (2005). +# Functional Data Analysis. Springer. diff --git a/examples/plot_magnitude_shape.py b/examples/plot_magnitude_shape.py index 9b4e19751..777b1524e 100644 --- a/examples/plot_magnitude_shape.py +++ b/examples/plot_magnitude_shape.py @@ -10,15 +10,14 @@ # sphinx_gallery_thumbnail_number = 2 +import matplotlib.pyplot as plt +import numpy as np + from skfda import datasets from skfda.exploratory.depth import IntegratedDepth from skfda.exploratory.depth.multivariate import SimplicialDepth from skfda.exploratory.visualization import MagnitudeShapePlot -import matplotlib.pyplot as plt -import numpy as np - - ############################################################################## # First, the Canadian Weather dataset is downloaded from the package 'fda' in # CRAN. It contains a FDataGrid with daily temperatures and precipitations, diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index 57dd64a00..5a34c267d 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -11,15 +11,13 @@ # sphinx_gallery_thumbnail_number = 2 -import skfda -from skfda.misc.metrics import pairwise_distance, lp_distance -from skfda.ml.classification import RadiusNeighborsClassifier - -from sklearn.model_selection import train_test_split - import matplotlib.pyplot as plt import numpy as np +from sklearn.model_selection import train_test_split +import skfda +from skfda.misc.metrics import PairwiseMetric, linf_distance +from skfda.ml.classification import RadiusNeighborsClassifier ############################################################################## # @@ -89,7 +87,7 @@ # Creation of pairwise distance -l_inf = pairwise_distance(lp_distance, p=np.inf) +l_inf = PairwiseMetric(linf_distance) distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' # Plot samples in the ball diff --git a/examples/plot_shift_registration.py b/examples/plot_shift_registration.py index e4838186f..4b6c128e6 100644 --- a/examples/plot_shift_registration.py +++ b/examples/plot_shift_registration.py @@ -63,8 +63,12 @@ # however, this effect is mitigated after the registration. # sinusoidal process without variation and noise -sine = make_sinusoidal_process(n_samples=1, phase_std=0, - amplitude_std=0, error_std=0) +sine = make_sinusoidal_process( + n_samples=1, + phase_std=0, + amplitude_std=0, + error_std=0, +) fig = fd_basis.mean().plot() fd_registered.mean().plot(fig) @@ -76,7 +80,6 @@ # The values of the shifts :math:`\delta_i`, stored in the attribute `deltas_` # may be relevant for further analysis, as they may be considered as nuisance # or random effects. -# print(shift_registration.deltas_) diff --git a/readthedocs-requirements.txt b/readthedocs-requirements.txt index a3cb14b8f..3b1d1b9d5 100644 --- a/readthedocs-requirements.txt +++ b/readthedocs-requirements.txt @@ -8,9 +8,9 @@ sphinx_rtd_theme sphinx-gallery pillow matplotlib -mpldatacursor setuptools>=41.2 multimethod>=1.2 findiff jupyter-sphinx -pytest \ No newline at end of file +pytest +sphinxcontrib-bibtex \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 29588726f..78c981489 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,5 @@ scipy setuptools Cython sklearn -mpldatacursor multimethod>=1.2 findiff diff --git a/setup.cfg b/setup.cfg index a3072c196..d8a7354cd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,7 +4,7 @@ test=pytest [tool:pytest] addopts = --doctest-modules doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS -norecursedirs = '.*', 'build', 'dist' '*.egg' 'venv' .svn _build docs/auto_examples examples +norecursedirs = '.*', 'build', 'dist' '*.egg' 'venv' .svn _build docs/auto_examples examples docs/auto_tutorial tutorial [flake8] ignore = @@ -27,12 +27,22 @@ ignore = # Google Python style is not RST until after processed by Napoleon # See https://github.com/peterjc/flake8-rst-docstrings/issues/17 RST201, RST203, RST301, + # assert is used by pytest tests + S101, # Line break occurred before a binary operator (antipattern) W503, + # Utils is used as a module name + WPS100, # Short names like X or y are common in scikit-learn WPS111, + # We do not like this underscored numbers convention + WPS114, + # Attributes in uppercase are used in enums + WPS115, # Trailing underscores are a scikit-learn convention WPS120, + # Cognitive complexity cannot be avoided at some modules + WPS232, # The number of imported things may be large, especially for typing WPS235, # We like local imports, thanks @@ -42,6 +52,8 @@ ignore = # We love f-strings WPS305, # Implicit string concatenation is useful for exception messages + WPS306, + # No base class needed WPS326, # We allow multiline conditions WPS337, @@ -49,6 +61,8 @@ ignore = WPS338, # We need multine loops WPS352, + # Assign to a subcript slice is normal behaviour in numpy + WPS362, # All keywords are beautiful WPS420, # We use nested imports sometimes, and it is not THAT bad @@ -60,8 +74,12 @@ ignore = WPS436, # Our private objects are fine to import WPS450, + # Numpy mixes bitwise and comparison operators + WPS465, # Explicit len compare is better than implicit WPS507, + # Comparison with not is not the same as with equality + WPS520, per-file-ignores = __init__.py: @@ -71,35 +89,42 @@ per-file-ignores = WPS235, # Logic is allowec in `__init__.py` WPS412 - + # There are many datasets _real_datasets.py: WPS202 # Tests benefit from magic numbers and fixtures test_*.py: WPS432, WPS442 + + # Examples are allowed to have imports in the middle, "commented code", call print and have magic numbers + plot_*.py: E402, E800, WPS421, WPS432 rst-directives = # These are sorted alphabetically - but that does not matter autosummary,data,currentmodule,deprecated, - glossary,moduleauthor,plot,testcode, + footbibliography,glossary, + jupyter-execute, + moduleauthor,plot,testcode, versionadded,versionchanged, rst-roles = - attr,class,func,meth,mod,obj,ref,term, - -allowed-domain-names = data, obj, result, val, value, values, var + attr,class,doc,footcite,footcite:ts,func,meth,mod,obj,ref,term, + +allowed-domain-names = data, obj, result, results, val, value, values, var # Needs to be tuned max-arguments = 10 max-attributes = 10 -max-line-complexity = 25 -max-methods = 30 -max-local-variables = 15 +max-cognitive-score = 30 max-expressions = 15 +max-imports = 20 +max-line-complexity = 30 +max-local-variables = 15 +max-methods = 30 max-module-expressions = 15 -max-module-members = 10 +max-module-members = 15 max-string-usages = 10 -max-cognitive-score = 30 +max-try-body-length = 4 ignore-decorators = (property)|(overload) @@ -121,6 +146,7 @@ multi_line_output = 3 include_trailing_comma = true use_parentheses = true combine_as_imports = 1 +skip_glob = **/plot_*.py plot_*.py [mypy] strict = True @@ -130,9 +156,15 @@ implicit_reexport = True [mypy-dcor.*] ignore_missing_imports = True +[mypy-fdasrsf.*] +ignore_missing_imports = True + [mypy-findiff.*] ignore_missing_imports = True +[mypy-joblib.*] +ignore_missing_imports = True + [mypy-matplotlib.*] ignore_missing_imports = True diff --git a/setup.py b/setup.py index 4a840bc26..cba429bfb 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ platforms=['any'], license='BSD', packages=find_packages(), - python_requires='>=3.6, <4', + python_requires='>=3.7, <4', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', @@ -53,10 +53,11 @@ 'License :: OSI Approved :: BSD License', 'Natural Language :: English', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Scientific/Engineering :: Mathematics', 'Topic :: Software Development :: Libraries :: Python Modules', + 'Typing :: Typed', ], install_requires=[ 'cython', @@ -64,8 +65,7 @@ 'fdasrsf>=2.2.0', 'findiff', 'matplotlib', - 'mpldatacursor', - 'multimethod>=1.2', + 'multimethod>=1.5', 'numpy>=1.16', 'pandas', 'rdata', diff --git a/skfda/_utils/__init__.py b/skfda/_utils/__init__.py index 8852c319e..61c9714ae 100644 --- a/skfda/_utils/__init__.py +++ b/skfda/_utils/__init__.py @@ -3,18 +3,20 @@ RandomStateLike, _cartesian_product, _check_array_key, + _check_compatible_fdata, _check_estimator, + _classifier_fit_depth_methods, _classifier_get_classes, - _domain_range, + _classifier_get_depth_methods, _evaluate_grid, - _FDataCallable, _int_to_real, - _pairwise_commutative, + _pairwise_symmetric, _reshape_eval_points, _same_domain, _to_array_maybe_ragged, + _to_domain_range, _to_grid, - _tuple_of_arrays, + _to_grid_points, check_is_univariate, nquad_vec, ) diff --git a/skfda/_utils/_utils.py b/skfda/_utils/_utils.py index ba6808e3d..b901099eb 100644 --- a/skfda/_utils/_utils.py +++ b/skfda/_utils/_utils.py @@ -1,53 +1,56 @@ -"""Module with generic methods""" +"""Module with generic methods.""" + +from __future__ import annotations import functools import numbers -from typing import Any, Optional, Sequence, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterable, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, + overload, +) import numpy as np import scipy.integrate +from numpy import ndarray from pandas.api.indexers import check_array_indexer - -from ..representation.evaluator import Evaluator +from sklearn.base import clone +from sklearn.preprocessing import LabelEncoder +from sklearn.utils.multiclass import check_classification_targets +from typing_extensions import Literal, Protocol + +from ..representation._typing import ( + ArrayLike, + DomainRange, + DomainRangeLike, + GridPoints, + GridPointsLike, +) +from ..representation.extrapolation import ExtrapolationLike RandomStateLike = Optional[Union[int, np.random.RandomState]] +if TYPE_CHECKING: + from ..exploratory.depth import Depth + from ..representation import FData, FDataGrid + from ..representation.basis import Basis + T = TypeVar("T", bound=FData) -class _FDataCallable(): - - def __init__(self, function, *, domain_range, n_samples=1): - - self.function = function - self.domain_range = domain_range - self.n_samples = n_samples - - def __call__(self, *args, **kwargs): - - return self.function(*args, **kwargs) - - def __len__(self): - - return self.n_samples - def __getitem__(self, key): - - def new_function(*args, **kwargs): - return self.function(*args, **kwargs)[key] - - tmp = np.empty(self.n_samples) - new_nsamples = len(tmp[key]) - - return _FDataCallable(new_function, - domain_range=self.domain_range, - n_samples=new_nsamples) - - -def check_is_univariate(fd): - """Checks if an FData is univariate and raises an error +def check_is_univariate(fd: FData) -> None: + """Check if an FData is univariate and raises an error. Args: - fd (:class:`~skfda.FData`): Functional object to check if is - univariate. + fd: Functional object to check if is univariate. Raises: ValueError: If it is not univariate, i.e., `fd.dim_domain != 1` or @@ -55,18 +58,44 @@ def check_is_univariate(fd): """ if fd.dim_domain != 1 or fd.dim_codomain != 1: - raise ValueError(f"The functional data must be univariate, i.e., " + - f"with dim_domain=1 " + - (f"" if fd.dim_domain == 1 - else f"(currently is {fd.dim_domain}) ") + - f"and dim_codomain=1 " + - (f"" if fd.dim_codomain == 1 else - f"(currently is {fd.dim_codomain})")) - - -def _to_grid(X, y, eval_points=None): + domain_str = ( + "" if fd.dim_domain == 1 + else f"(currently is {fd.dim_domain}) " + ) + + codomain_str = ( + "" if fd.dim_codomain == 1 + else f"(currently is {fd.dim_codomain})" + ) + + raise ValueError( + f"The functional data must be univariate, i.e., " + f"with dim_domain=1 {domain_str}" + f"and dim_codomain=1 {codomain_str}", + ) + + +def _check_compatible_fdata(fdata1: FData, fdata2: FData) -> None: + """Check that fdata is compatible.""" + if (fdata1.dim_domain != fdata2.dim_domain): + raise ValueError( + f"Functional data has incompatible domain dimensions: " + f"{fdata1.dim_domain} != {fdata2.dim_domain}", + ) + + if (fdata1.dim_codomain != fdata2.dim_codomain): + raise ValueError( + f"Functional data has incompatible codomain dimensions: " + f"{fdata1.dim_codomain} != {fdata2.dim_codomain}", + ) + + +def _to_grid( + X: FData, + y: FData, + eval_points: Optional[np.ndarray] = None, +) -> Tuple[FDataGrid, FDataGrid]: """Transform a pair of FDatas in grids to perform calculations.""" - from .. import FDataGrid x_is_grid = isinstance(X, FDataGrid) y_is_grid = isinstance(y, FDataGrid) @@ -85,8 +114,8 @@ def _to_grid(X, y, eval_points=None): return X, y -def _tuple_of_arrays(original_array): - """Convert to a list of arrays. +def _to_grid_points(grid_points_like: GridPointsLike) -> GridPoints: + """Convert to grid points. If the original list is one-dimensional (e.g. [1, 2, 3]), return list to array (in this case [array([1, 2, 3])]). @@ -98,41 +127,43 @@ def _tuple_of_arrays(original_array): In any other case the behaviour is unespecified. """ - unidimensional = False - try: - iter(original_array) - except TypeError: - original_array = [original_array] + if not isinstance(grid_points_like, Iterable): + grid_points_like = [grid_points_like] - try: - iter(original_array[0]) - except TypeError: + if not isinstance(grid_points_like[0], Iterable): unidimensional = True if unidimensional: - return (_int_to_real(np.asarray(original_array)),) - else: - return tuple(_int_to_real(np.asarray(i)) for i in original_array) + return (_int_to_real(np.asarray(grid_points_like)),) + return tuple(_int_to_real(np.asarray(i)) for i in grid_points_like) -def _domain_range(sequence): - try: - iter(sequence[0]) - except TypeError: - sequence = (sequence,) +def _to_domain_range(sequence: DomainRangeLike) -> DomainRange: + """Convert sequence to a proper domain range.""" + seq_aux = cast( + Sequence[Sequence[float]], + (sequence,) if isinstance(sequence[0], numbers.Real) else sequence, + ) - sequence = tuple(tuple(s) for s in sequence) + tuple_aux = tuple(tuple(s) for s in seq_aux) - if not all(len(s) == 2 for s in sequence): - raise ValueError("Domain intervals should have 2 bounds each") + if not all(len(s) == 2 and s[0] <= s[1] for s in tuple_aux): + raise ValueError( + "Domain intervals should have 2 bounds for " + "dimension: (lower, upper).", + ) - return sequence + return cast(DomainRange, tuple_aux) -def _to_array_maybe_ragged(array, *, row_shape=None): +def _to_array_maybe_ragged( + array: Iterable[ArrayLike], + *, + row_shape: Optional[Sequence[int]] = None, +) -> np.ndarray: """ Convert to an array where each element may or may not be of equal length. @@ -140,7 +171,7 @@ def _to_array_maybe_ragged(array, *, row_shape=None): Otherwise it is a ragged array. """ - def convert_row(row): + def convert_row(row: ArrayLike) -> np.ndarray: r = np.array(row) if row_shape is not None: @@ -153,31 +184,62 @@ def convert_row(row): if all(s == shapes[0] for s in shapes): return np.array(array_list) - else: - res = np.empty(len(array_list), dtype=np.object_) - for i, a in enumerate(array_list): - res[i] = a + res = np.empty(len(array_list), dtype=np.object_) - return res + for i, a in enumerate(array_list): + res[i] = a + + return res -def _cartesian_product(axes, flatten=True, return_shape=False): - """Computes the cartesian product of the axes. +@overload +def _cartesian_product( + axes: Sequence[np.ndarray], + *, + flatten: bool = True, + return_shape: Literal[False] = False, +) -> np.ndarray: + pass + + +@overload +def _cartesian_product( + axes: Sequence[np.ndarray], + *, + flatten: bool = True, + return_shape: Literal[True], +) -> Tuple[np.ndarray, Tuple[int, ...]]: + pass + + +def _cartesian_product( # noqa: WPS234 + axes: Sequence[np.ndarray], + *, + flatten: bool = True, + return_shape: bool = False, +) -> Union[np.ndarray, Tuple[np.ndarray, Tuple[int, ...]]]: + """ + Compute the cartesian product of the axes. Computes the cartesian product of the axes and returns a numpy array of 1 dimension with all the possible combinations, for an arbitrary number of dimensions. Args: - Axes (array_like): List with axes. + axes: List with axes. + flatten: Whether to return the flatten array or keep one dimension per + axis. + return_shape: If ``True`` return the shape of the array before + flattening. - Return: - (np.ndarray): Numpy 2-D array with all the possible combinations. + Returns: + Numpy 2-D array with all the possible combinations. The entry (i,j) represent the j-th coordinate of the i-th point. + If ``return_shape`` is ``True`` returns also the shape of the array + before flattening. Examples: - >>> from skfda._utils import _cartesian_product >>> axes = [[0,1],[2,3]] >>> _cartesian_product(axes) @@ -197,7 +259,6 @@ def _cartesian_product(axes, flatten=True, return_shape=False): >>> _cartesian_product(axes) array([[0], [1]]) - """ cartesian = np.stack(np.meshgrid(*axes, indexing='ij'), -1) @@ -208,24 +269,56 @@ def _cartesian_product(axes, flatten=True, return_shape=False): if return_shape: return cartesian, shape - else: - return cartesian + + return cartesian -def _same_domain(fd, fd2): +def _same_domain(fd: Union[Basis, FData], fd2: Union[Basis, FData]) -> bool: """Check if the domain range of two objects is the same.""" return np.array_equal(fd.domain_range, fd2.domain_range) +@overload +def _reshape_eval_points( + eval_points: ArrayLike, + *, + aligned: Literal[True], + n_samples: int, + dim_domain: int, +) -> np.ndarray: + pass + + +@overload +def _reshape_eval_points( + eval_points: Sequence[ArrayLike], + *, + aligned: Literal[True], + n_samples: int, + dim_domain: int, +) -> np.ndarray: + pass + + +@overload +def _reshape_eval_points( + eval_points: Union[ArrayLike, Sequence[ArrayLike]], + *, + aligned: bool, + n_samples: int, + dim_domain: int, +) -> np.ndarray: + pass + + def _reshape_eval_points( - eval_points: np.ndarray, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], *, aligned: bool, n_samples: int, dim_domain: int, ) -> np.ndarray: - """Convert and reshape the eval_points to ndarray with the - corresponding shape. + """Convert and reshape the eval_points to ndarray. Args: eval_points: Evaluation points to be reshaped. @@ -242,47 +335,58 @@ def _reshape_eval_points( x `dim_domain`. """ - if aligned: eval_points = np.asarray(eval_points) else: + eval_points = cast(Iterable[ArrayLike], eval_points) + eval_points = _to_array_maybe_ragged( - eval_points, row_shape=(-1, dim_domain)) + eval_points, + row_shape=(-1, dim_domain), + ) # Case evaluation of a single value, i.e., f(0) # Only allowed for aligned evaluation - if aligned and (eval_points.shape == (dim_domain,) - or (eval_points.ndim == 0 and dim_domain == 1)): + if aligned and ( + eval_points.shape == (dim_domain,) + or (eval_points.ndim == 0 and dim_domain == 1) + ): eval_points = np.array([eval_points]) if aligned: # Samples evaluated at same eval points - - eval_points = eval_points.reshape((eval_points.shape[0], - dim_domain)) + eval_points = eval_points.reshape( + (eval_points.shape[0], dim_domain), + ) else: # Different eval_points for each sample if eval_points.shape[0] != n_samples: - - raise ValueError(f"eval_points should be a list " - f"of length {n_samples} with the " - f"evaluation points for each sample.") + raise ValueError( + f"eval_points should be a list " + f"of length {n_samples} with the " + f"evaluation points for each sample.", + ) return eval_points -def _one_grid_to_points(axes, *, dim_domain): +def _one_grid_to_points( + axes: GridPointsLike, + *, + dim_domain: int, +) -> Tuple[np.ndarray, Tuple[int, ...]]: """ Convert a list of ndarrays, one per domain dimension, in the points. Returns also the shape containing the information of how each point is formed. """ - axes = _tuple_of_arrays(axes) + axes = _to_grid_points(axes) if len(axes) != dim_domain: - raise ValueError(f"Length of axes should be " - f"{dim_domain}") + raise ValueError( + f"Length of axes should be {dim_domain}", + ) cartesian, shape = _cartesian_product(axes, return_shape=True) @@ -292,17 +396,59 @@ def _one_grid_to_points(axes, *, dim_domain): return cartesian, shape +class EvaluateMethod(Protocol): + """Evaluation method.""" + + def __call__( + self, + __eval_points: np.ndarray, # noqa: WPS112 + extrapolation: Optional[ExtrapolationLike], + aligned: bool, + ) -> np.ndarray: + """Evaluate a function.""" + pass + + +@overload def _evaluate_grid( - axes: Sequence[np.ndarray], + axes: GridPointsLike, + *, + evaluate_method: EvaluateMethod, + n_samples: int, + dim_domain: int, + dim_codomain: int, + extrapolation: Optional[ExtrapolationLike] = None, + aligned: Literal[True] = True, +) -> np.ndarray: + pass + + +@overload +def _evaluate_grid( + axes: Iterable[GridPointsLike], *, - evaluate_method: Any, + evaluate_method: EvaluateMethod, n_samples: int, dim_domain: int, dim_codomain: int, - extrapolation: Optional[Union[str, Evaluator]] = None, + extrapolation: Optional[ExtrapolationLike] = None, + aligned: Literal[False], +) -> np.ndarray: + pass + + +def _evaluate_grid( # noqa: WPS234 + axes: Union[GridPointsLike, Iterable[GridPointsLike]], + *, + evaluate_method: EvaluateMethod, + n_samples: int, + dim_domain: int, + dim_codomain: int, + extrapolation: Optional[ExtrapolationLike] = None, aligned: bool = True, ) -> np.ndarray: - """Evaluate the functional object in the cartesian grid. + """ + Evaluate the functional object in the cartesian grid. This method is called internally by :meth:`evaluate` when the argument `grid` is True. @@ -327,12 +473,20 @@ def _evaluate_grid( Args: axes: List of axes to generated the grid where the object will be evaluated. + evaluate_method: Function used to evaluate the functional object. + n_samples: Number of samples. + dim_domain: Domain dimension. + dim_codomain: Codomain dimension. extrapolation: Controls the extrapolation mode for elements outside the domain range. By default it is used the mode defined during the instance of the object. aligned: If False evaluates each sample in a different grid. + evaluate_method: method to use to evaluate the points + n_samples: number of samples + dim_domain: dimension of the domain + dim_codomain: dimensions of the codomain Returns: Numpy array with dim_domain + 1 dimensions with @@ -343,50 +497,65 @@ def _evaluate_grid( dimension. """ - # Compute intersection points and resulting shapes if aligned: + axes = cast(GridPointsLike, axes) + eval_points, shape = _one_grid_to_points(axes, dim_domain=dim_domain) else: - axes = list(axes) + axes_per_sample = cast(Iterable[GridPointsLike], axes) + + axes_per_sample = list(axes_per_sample) - if len(axes) != n_samples: - raise ValueError("Should be provided a list of axis per " - "sample") + eval_points_tuple, shape_tuple = zip( + *[ + _one_grid_to_points(a, dim_domain=dim_domain) + for a in axes_per_sample + ], + ) - eval_points, shape = zip( - *[_one_grid_to_points(a, dim_domain=dim_domain) for a in axes]) + if len(eval_points_tuple) != n_samples: + raise ValueError( + "Should be provided a list of axis per sample", + ) - eval_points = _to_array_maybe_ragged(eval_points) + eval_points = _to_array_maybe_ragged(eval_points_tuple) # Evaluate the points - res = evaluate_method(eval_points, - extrapolation=extrapolation, - aligned=aligned) + evaluated = evaluate_method( + eval_points, + extrapolation=extrapolation, + aligned=aligned, + ) # Reshape the result if aligned: - res = res.reshape([n_samples] + - list(shape) + [dim_codomain]) + res = evaluated.reshape( + [n_samples] + list(shape) + [dim_codomain], + ) else: res = _to_array_maybe_ragged([ r.reshape(list(s) + [dim_codomain]) - for r, s in zip(res, shape)]) + for r, s in zip(evaluated, shape_tuple) + ]) return res -def nquad_vec(func, ranges): - +def nquad_vec( + func: Callable[[np.ndarray], np.ndarray], + ranges: Sequence[Tuple[float, float]], +) -> np.ndarray: + """Perform multiple integration of vector valued functions.""" initial_depth = len(ranges) - 1 - def integrate(*args, depth): + def integrate(*args: Any, depth: int) -> np.ndarray: # noqa: WPS430 if depth == 0: f = functools.partial(func, *args) @@ -398,20 +567,67 @@ def integrate(*args, depth): return integrate(depth=initial_depth) -def _pairwise_commutative(function, arg1, arg2=None, **kwargs): +def _map_in_batches( + function: Callable[..., np.ndarray], + arguments: Tuple[Union[FData, np.ndarray], ...], + indexes: Tuple[np.ndarray, ...], + memory_per_batch: Optional[int] = None, + **kwargs: Any, +) -> np.ndarray: """ - Compute pairwise a commutative function. + Map a function over samples of FData or ndarray tuples efficiently. + + This function prevents a large set of indexes to use all available + memory and hang the PC. """ - if arg2 is None: + if memory_per_batch is None: + # 256MB is not too big + memory_per_batch = 256 * 1024 * 1024 # noqa: WPS432 + + memory_per_element = sum(a.nbytes // len(a) for a in arguments) + n_elements_per_batch_allowed = memory_per_batch // memory_per_element + if n_elements_per_batch_allowed < 1: + raise ValueError("Too few memory allowed for the operation") + + n_indexes = len(indexes[0]) - indices = np.triu_indices(len(arg1)) + assert all(n_indexes == len(i) for i in indexes) + + batches: List[np.ndarray] = [] + + for pos in range(0, n_indexes, n_elements_per_batch_allowed): + batch_args = tuple( + a[i[pos:pos + n_elements_per_batch_allowed]] + for a, i in zip(arguments, indexes) + ) + + batches.append(function(*batch_args, **kwargs)) + + return np.concatenate(batches, axis=0) + + +def _pairwise_symmetric( + function: Callable[..., np.ndarray], + arg1: Union[FData, np.ndarray], + arg2: Optional[Union[FData, np.ndarray]] = None, + memory_per_batch: Optional[int] = None, + **kwargs: Any, +) -> np.ndarray: + """Compute pairwise a commutative function.""" + dim1 = len(arg1) + if arg2 is None or arg2 is arg1: + indices = np.triu_indices(dim1) - matrix = np.empty((len(arg1), len(arg1))) + matrix = np.empty((dim1, dim1)) - triang_vec = function( - arg1[indices[0]], arg1[indices[1]], - **kwargs) + triang_vec = _map_in_batches( + function, + (arg1, arg1), + indices, + memory_per_batch=memory_per_batch, + **kwargs, + ) # Set upper matrix matrix[indices] = triang_vec @@ -421,42 +637,48 @@ def _pairwise_commutative(function, arg1, arg2=None, **kwargs): return matrix - else: + dim2 = len(arg2) + indices = np.indices((dim1, dim2)) - indices = np.indices((len(arg1), len(arg2))) + vec = _map_in_batches( + function, + (arg1, arg2), + (indices[0].ravel(), indices[1].ravel()), + memory_per_batch=memory_per_batch, + **kwargs, + ) - return function( - arg1[indices[0].ravel()], arg2[indices[1].ravel()], - **kwargs).reshape( - (len(arg1), len(arg2))) + return vec.reshape((dim1, dim2)) -def _int_to_real(array): - """ - Convert integer arrays to floating point. - """ +def _int_to_real(array: np.ndarray) -> np.ndarray: + """Convert integer arrays to floating point.""" return array + 0.0 -def _check_array_key(array, key): - """ - Checks a getitem key. - """ - +def _check_array_key(array: np.ndarray, key: Any) -> Any: + """Check a getitem key.""" key = check_array_indexer(array, key) + if isinstance(key, tuple): + non_ellipsis = [i for i in key if i is not Ellipsis] + if len(non_ellipsis) > 1: + raise KeyError(key) + key = non_ellipsis[0] if isinstance(key, numbers.Integral): # To accept also numpy ints key = int(key) key = range(len(array))[key] return slice(key, key + 1) - else: - return key + + return key def _check_estimator(estimator): from sklearn.utils.estimator_checks import ( - check_get_params_invariance, check_set_params) + check_get_params_invariance, + check_set_params, + ) name = estimator.__name__ instance = estimator() @@ -464,9 +686,7 @@ def _check_estimator(estimator): check_set_params(name, instance) -def _classifier_get_classes(y): - from sklearn.utils.multiclass import check_classification_targets - from sklearn.preprocessing import LabelEncoder +def _classifier_get_classes(y: ndarray) -> Tuple[ndarray, ndarray]: check_classification_targets(y) @@ -476,6 +696,35 @@ def _classifier_get_classes(y): classes = le.classes_ if classes.size < 2: - raise ValueError(f'The number of classes has to be greater than' - f' one; got {classes.size} class') + raise ValueError( + f'The number of classes has to be greater than' + f'one; got {classes.size} class', + ) return classes, y_ind + + +def _classifier_get_depth_methods( + classes: ndarray, + X: T, + y_ind: ndarray, + depth_methods: Sequence[Depth[T]], +) -> Sequence[Depth[T]]: + return [ + clone(depth_method).fit(X[y_ind == cur_class]) + for cur_class in range(classes.size) + for depth_method in depth_methods + ] + + +def _classifier_fit_depth_methods( + X: T, + y: ndarray, + depth_methods: Sequence[Depth[T]], +) -> Tuple[ndarray, Sequence[Depth[T]]]: + classes, y_ind = _classifier_get_classes(y) + + class_depth_methods_ = _classifier_get_depth_methods( + classes, X, y_ind, depth_methods, + ) + + return classes, class_depth_methods_ diff --git a/skfda/datasets/__init__.py b/skfda/datasets/__init__.py index 7fa549473..d5f6f0cd7 100644 --- a/skfda/datasets/__init__.py +++ b/skfda/datasets/__init__.py @@ -1,12 +1,22 @@ -from ._real_datasets import (fdata_constructor, fetch_cran, - fetch_ucr, - fetch_phoneme, fetch_growth, - fetch_tecator, fetch_medflies, - fetch_weather, fetch_aemet, - fetch_octane, fetch_gait) -from ._samples_generators import (make_gaussian, - make_gaussian_process, - make_sinusoidal_process, - make_multimodal_samples, - make_multimodal_landmarks, - make_random_warping) +from ._real_datasets import ( + fdata_constructor, + fetch_aemet, + fetch_cran, + fetch_gait, + fetch_growth, + fetch_handwriting, + fetch_medflies, + fetch_octane, + fetch_phoneme, + fetch_tecator, + fetch_ucr, + fetch_weather, +) +from ._samples_generators import ( + make_gaussian, + make_gaussian_process, + make_multimodal_landmarks, + make_multimodal_samples, + make_random_warping, + make_sinusoidal_process, +) diff --git a/skfda/datasets/_real_datasets.py b/skfda/datasets/_real_datasets.py index a1d027134..51aaf2c8a 100644 --- a/skfda/datasets/_real_datasets.py +++ b/skfda/datasets/_real_datasets.py @@ -3,13 +3,12 @@ import numpy as np import pandas as pd +import rdata from numpy import ndarray from pandas import DataFrame, Series from sklearn.utils import Bunch from typing_extensions import Literal -import rdata - from .. import FDataGrid @@ -437,7 +436,7 @@ def fetch_growth( target_name = "sex" target_categories = ["male", "female"] frame = None - + if as_frame: sex = pd.Categorical.from_codes(sex, categories=target_categories) frame = pd.DataFrame({ @@ -449,7 +448,7 @@ def fetch_growth( if return_X_y: return curves, sex - + return Bunch( data=curves, target=sex, @@ -807,15 +806,17 @@ def fetch_weather( else: feature_names = [curve_name] X = curves - meta = np.array(list(zip( - data["place"], - data["province"], - np.asarray(data["coordinates"])[:, 0], - np.asarray(data["coordinates"])[:, 1], - data["geogindex"], - np.asarray(data["monthlyTemp"]).T, - np.asarray(data["monthlyPrecip"]).T, - ))) + meta = np.concatenate( + ( + np.array(data["place"], dtype=np.object_)[:, np.newaxis], + np.array(data["province"], dtype=np.object_)[:, np.newaxis], + np.asarray(data["coordinates"], dtype=np.object_), + np.array(data["geogindex"], dtype=np.object_)[:, np.newaxis], + np.asarray(data["monthlyTemp"]).T.tolist(), + np.asarray(data["monthlyPrecip"]).T.tolist(), + ), + axis=1, + ) meta_names = [ "place", "province", @@ -1204,3 +1205,99 @@ def fetch_gait( if fetch_gait.__doc__ is not None: # docstrings can be stripped off fetch_gait.__doc__ += _gait_descr + _param_descr + +_handwriting_descr = """ + Data representing the X-Y coordinates along time obtained while + writing the word "fda". The sample contains 20 instances measured over + 2.3 seconds that had been aligned for a better understanding. Each instance + is formed by 1401 coordinate values. + + References: + Ramsay, James O., and Silverman, Bernard W. (2006), + Functional Data Analysis, 2nd ed. , Springer, New York. +""" + + +@overload +def fetch_handwriting( + *, + return_X_y: Literal[False] = False, + as_frame: bool = False, +) -> Bunch: + pass + + +@overload +def fetch_handwriting( + *, + return_X_y: Literal[True], + as_frame: Literal[False] = False, +) -> Tuple[FDataGrid, None]: + pass + + +@overload +def fetch_handwriting( + *, + return_X_y: Literal[True], + as_frame: Literal[True], +) -> Tuple[DataFrame, None]: + pass + + +def fetch_handwriting( + return_X_y: bool = False, + as_frame: bool = False, +) -> Union[Bunch, Tuple[FDataGrid, None], Tuple[DataFrame, None]]: + """ + Load the HANDWRIT dataset. + + The data is obtained from the R package 'fda' from CRAN. + + """ + descr = _handwriting_descr + + raw_data = _fetch_fda("handwrit") + + data = raw_data["handwrit"] + + data_matrix = np.asarray(data) + data_matrix = np.transpose(data_matrix, axes=(1, 0, 2)) + grid_points = np.asarray(data.coords.get('dim_0'), np.float64) + sample_names = np.asarray(data.coords.get('dim_1')) + feature_name = 'handwrit' + + curves = FDataGrid( + data_matrix=data_matrix, + grid_points=grid_points, + dataset_name=feature_name, + sample_names=sample_names, + argument_names=("time",), + coordinate_names=( + "x coordinates", + "y coordinates", + ), + ) + + frame = None + + if as_frame: + curves = pd.DataFrame({feature_name: curves}) + frame = curves + + if return_X_y: + return curves, None + + return Bunch( + data=curves, + target=None, + frame=frame, + categories={}, + feature_names=[feature_name], + target_names=[], + DESCR=descr, + ) + + +if fetch_handwriting.__doc__ is not None: # docstrings can be stripped off + fetch_handwriting.__doc__ += _handwriting_descr + _param_descr diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index fd038c1ea..1fd21cc6b 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -1,51 +1,70 @@ -import scipy.integrate -from scipy.stats import multivariate_normal -import sklearn.utils +import itertools +from typing import Callable, Optional, Sequence, Union import numpy as np +import sklearn.utils + +import scipy.integrate +from scipy.stats import multivariate_normal from .. import FDataGrid -from .._utils import _cartesian_product +from .._utils import RandomStateLike, _cartesian_product, _to_grid_points from ..misc import covariances from ..preprocessing.registration import normalize_warping +from ..representation._typing import DomainRangeLike, GridPointsLike from ..representation.interpolation import SplineInterpolation +MeanCallable = Callable[[np.ndarray], np.ndarray] +CovarianceCallable = Callable[[np.ndarray, np.ndarray], np.ndarray] + +MeanLike = Union[float, np.ndarray, MeanCallable] +CovarianceLike = Union[None, np.ndarray, CovarianceCallable] -def make_gaussian(n_samples: int = 100, *, - grid_points, - domain_range=None, - mean=0, cov=None, noise: float = 0., - random_state=None): - """Generate Gaussian random fields. - - Args: - n_samples: The total number of trajectories. - grid_points: Sample points for the evaluation grid of the - Gaussian field. - mean: The mean function of the random field. Can be a callable - accepting a vector with the locations, or a vector with - appropriate size. - cov: The covariance function of the process. Can be a - callable accepting two vectors with the locations, or a - matrix with appropriate size. By default, - the Brownian covariance function is used. - noise: Standard deviation of Gaussian noise added to the data. - random_state: Random state. - - Returns: - :class:`FDataGrid` object comprising all the trajectories. - - See also: - :func:`make_gaussian_process`: Simpler function for generating - Gaussian processes. +def make_gaussian( + n_samples: int = 100, + *, + grid_points: GridPointsLike, + domain_range: Optional[DomainRangeLike] = None, + mean: MeanLike = 0, + cov: CovarianceLike = None, + noise: float = 0, + random_state: RandomStateLike = None, +) -> FDataGrid: """ + Generate Gaussian random fields. + Args: + n_samples: The total number of trajectories. + grid_points: Sample points for the evaluation grid of the + Gaussian field. + domain_range: The domain range of the returned functional + observations. + mean: The mean function of the random field. Can be a callable + accepting a vector with the locations, or a vector with + appropriate size. + cov: The covariance function of the process. Can be a + callable accepting two vectors with the locations, or a + matrix with appropriate size. By default, + the Brownian covariance function is used. + noise: Standard deviation of Gaussian noise added to the data. + random_state: Random state. + + Returns: + :class:`FDataGrid` object comprising all the trajectories. + + See also: + :func:`make_gaussian_process`: Simpler function for generating + Gaussian processes. + + """ random_state = sklearn.utils.check_random_state(random_state) if cov is None: cov = covariances.Brownian() + grid_points = _to_grid_points(grid_points) + input_points = _cartesian_product(grid_points) covariance = covariances._execute_covariance( @@ -61,61 +80,85 @@ def make_gaussian(n_samples: int = 100, *, mu += np.ravel(mean) data_matrix = random_state.multivariate_normal( - mu.ravel(), covariance, n_samples) + mu.ravel(), + covariance, + n_samples, + ) data_matrix = data_matrix.reshape( - [n_samples] + [len(t) for t in grid_points] + [-1]) - - return FDataGrid(grid_points=grid_points, data_matrix=data_matrix, - domain_range=domain_range) + [n_samples] + [len(t) for t in grid_points] + [-1], + ) + + return FDataGrid( + grid_points=grid_points, + data_matrix=data_matrix, + domain_range=domain_range, + ) + + +def make_gaussian_process( + n_samples: int = 100, + n_features: int = 100, + *, + start: float = 0, + stop: float = 1, + mean: MeanLike = 0, + cov: CovarianceLike = None, + noise: float = 0, + random_state: RandomStateLike = None, +) -> FDataGrid: + """Generate Gaussian process trajectories. + Args: + n_samples: The total number of trajectories. + n_features: The total number of features (points of evaluation). + start: Starting point of the trajectories. + stop: Ending point of the trajectories. + mean: The mean function of the process. Can be a callable accepting + a vector with the locations, or a vector with length + ``n_features``. + cov: The covariance function of the process. Can be a + callable accepting two vectors with the locations, or a + matrix with size ``n_features`` x ``n_features``. By default, + the Brownian covariance function is used. + noise: Standard deviation of Gaussian noise added to the data. + random_state: Random state. -def make_gaussian_process(n_samples: int = 100, n_features: int = 100, *, - start: float = 0., stop: float = 1., - mean=0, cov=None, noise: float = 0., - random_state=None): - """Generate Gaussian process trajectories. + Returns: + :class:`FDataGrid` object comprising all the trajectories. - Args: - n_samples: The total number of trajectories. - n_features: The total number of features (points of evaluation). - start: Starting point of the trajectories. - stop: Ending point of the trajectories. - mean: The mean function of the process. Can be a callable accepting - a vector with the locations, or a vector with length - ``n_features``. - cov: The covariance function of the process. Can be a - callable accepting two vectors with the locations, or a - matrix with size ``n_features`` x ``n_features``. By default, - the Brownian covariance function is used. - noise: Standard deviation of Gaussian noise added to the data. - random_state: Random state. - - Returns: - :class:`FDataGrid` object comprising all the trajectories. - - See also: - :func:`make_gaussian`: More general function that allows to - select the points of evaluation and to - generate data in higer dimensions. + See also: + :func:`make_gaussian`: More general function that allows to + select the points of evaluation and to + generate data in higer dimensions. """ - t = np.linspace(start, stop, n_features) - return make_gaussian(n_samples=n_samples, - grid_points=[t], - mean=mean, cov=cov, - noise=noise, - random_state=random_state) - - -def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, - start: float = 0., stop: float = 1., - period: float = 1., phase_mean: float = 0., - phase_std: float = .6, amplitude_mean: float = 1., - amplitude_std: float = .05, error_std: float = .2, - random_state=None): + return make_gaussian( + n_samples=n_samples, + grid_points=[t], + mean=mean, + cov=cov, + noise=noise, + random_state=random_state, + ) + + +def make_sinusoidal_process( + n_samples: int = 15, + n_features: int = 100, + *, + start: float = 0, + stop: float = 1, + period: float = 1, + phase_mean: float = 0, + phase_std: float = 0.6, + amplitude_mean: float = 1, + amplitude_std: float = 0.05, + error_std: float = 0.2, + random_state: RandomStateLike = None, +) -> FDataGrid: r"""Generate sinusoidal proccess. Each sample :math:`x_i(t)` is generated as: @@ -146,16 +189,20 @@ def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, :class:`FDataGrid` object comprising all the samples. """ - random_state = sklearn.utils.check_random_state(random_state) t = np.linspace(start, stop, n_features) - alpha = np.diag(random_state.normal(amplitude_mean, amplitude_std, - n_samples)) + alpha = np.diag(random_state.normal( + amplitude_mean, + amplitude_std, + n_samples, + )) - phi = np.outer(random_state.normal(phase_mean, phase_std, n_samples), - np.ones(n_features)) + phi = np.outer( + random_state.normal(phase_mean, phase_std, n_samples), + np.ones(n_features), + ) error = random_state.normal(0, error_std, (n_samples, n_features)) @@ -164,10 +211,17 @@ def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, return FDataGrid(grid_points=t, data_matrix=y) -def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, - dim_domain: int = 1, dim_codomain: int = 1, - start: float = -1, stop: float = 1, - std: float = .05, random_state=None): +def make_multimodal_landmarks( + n_samples: int = 15, + *, + n_modes: int = 1, + dim_domain: int = 1, + dim_codomain: int = 1, + start: float = -1, + stop: float = 1, + std: float = 0.05, + random_state: RandomStateLike = None, +) -> np.ndarray: """Generate landmarks points. Used by :func:`make_multimodal_samples` to generate the location of the @@ -196,30 +250,43 @@ def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, :class:`np.ndarray` with the location of the modes, where the component (i,j,k) corresponds to the mode k of the image dimension j of the sample i. - """ + """ random_state = sklearn.utils.check_random_state(random_state) modes_location = np.linspace(start, stop, n_modes + 2)[1:-1] - modes_location = np.repeat(modes_location[:, np.newaxis], dim_domain, - axis=1) - - variation = random_state.multivariate_normal((0,) * dim_domain, - std * np.eye(dim_domain), - size=(n_samples, - dim_codomain, - n_modes)) + modes_location = np.repeat( + modes_location[:, np.newaxis], + dim_domain, + axis=1, + ) + + variation = random_state.multivariate_normal( + (0,) * dim_domain, + std * np.eye(dim_domain), + size=(n_samples, dim_codomain, n_modes), + ) return modes_location + variation -def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, - points_per_dim: int = 100, dim_domain: int = 1, - dim_codomain: int = 1, start: float = -1, - stop: float = 1., std: float = .05, - mode_std: float = .02, noise: float = .0, - modes_location=None, random_state=None): - r"""Generate multimodal samples. +def make_multimodal_samples( + n_samples: int = 15, + *, + n_modes: int = 1, + points_per_dim: int = 100, + dim_domain: int = 1, + dim_codomain: int = 1, + start: float = -1, + stop: float = 1, + std: float = 0.05, + mode_std: float = 0.02, + noise: float = 0, + modes_location: Optional[Sequence[float]] = None, + random_state: RandomStateLike = None, +) -> FDataGrid: + r""" + Generate multimodal samples. Each sample :math:`x_i(t)` is proportional to a gaussian mixture, generated as the sum of multiple pdf of multivariate normal distributions with @@ -256,26 +323,28 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, Returns: :class:`FDataGrid` object comprising all the samples. - """ + """ random_state = sklearn.utils.check_random_state(random_state) if modes_location is None: - location = make_multimodal_landmarks(n_samples=n_samples, - n_modes=n_modes, - dim_domain=dim_domain, - dim_codomain=dim_codomain, - start=start, - stop=stop, - std=std, - random_state=random_state) + location = make_multimodal_landmarks( + n_samples=n_samples, + n_modes=n_modes, + dim_domain=dim_domain, + dim_codomain=dim_codomain, + start=start, + stop=stop, + std=std, + random_state=random_state, + ) else: - location = np.asarray(modes_location) - shape = (n_samples, dim_codomain, n_modes, dim_domain) - location = location.reshape(shape) + location = np.asarray(modes_location).reshape( + (n_samples, dim_codomain, n_modes, dim_domain), + ) axis = np.linspace(start, stop, points_per_dim) @@ -299,13 +368,16 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, # Covariance matrix of the samples cov = mode_std * np.eye(dim_domain) - import itertools - for i, j, k in itertools.product(range(n_samples), - range(dim_codomain), - range(n_modes)): - data_matrix[i, ..., j] += multivariate_normal.pdf(evaluation_grid, - location[i, j, k], - cov) + for i, j, k in itertools.product( + range(n_samples), + range(dim_codomain), + range(n_modes), + ): + data_matrix[i, ..., j] += multivariate_normal.pdf( + evaluation_grid, + location[i, j, k], + cov, + ) # Constant to make modes value aprox. 1 data_matrix *= (2 * np.pi * mode_std) ** (dim_domain / 2) @@ -315,16 +387,23 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, return FDataGrid(grid_points=grid_points, data_matrix=data_matrix) -def make_random_warping(n_samples: int = 15, n_features: int = 100, *, - start: float = 0., stop: float = 1., sigma: float = 1., - shape_parameter: float = 50, n_random: int = 4, - random_state=None): +def make_random_warping( + n_samples: int = 15, + n_features: int = 100, + *, + start: float = 0, + stop: float = 1, + sigma: float = 1, + shape_parameter: float = 50, + n_random: int = 4, + random_state: RandomStateLike = None, +) -> FDataGrid: r"""Generate random warping functions. - Let :math:`v(t)` be a randomly generated function defined in :math:`[0,1]` + Let :math:`v(t)` be a randomly generated function defined in :math:`[0,1]` - .. math:: - v(t) = \sum_{j=0}^{N} a_j \sin(\frac{2 \pi j}{K}t) + b_j + .. math:: + sv(t) = \sum_{j=0}^{N} a_j \sin(\frac{2 \pi j}{K}t) + b_j \cos(\frac{2 \pi j}{K}t) where :math:`a_j, b_j \sim N(0, \sigma)`. @@ -357,9 +436,9 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, random_state: Random state. Returns: - :class:`FDataGrid` object comprising all the samples. + Object comprising all the samples. - """ + """ # Based on the original implementation of J. D. Tucker in the # package python_fdasrsf . @@ -376,8 +455,10 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, time = np.outer(np.linspace(0, 1, n_features), np.ones(n_samples)) # Operates trasposed to broadcast dimensions - v = np.outer(np.ones(n_features), - random_state.normal(scale=sqrt_sigma, size=n_samples)) + v = np.outer( + np.ones(n_features), + random_state.normal(scale=sqrt_sigma, size=n_samples), + ) for j in range(2, 2 + n_random): alpha = random_state.normal(scale=sqrt_sigma, size=(2, n_samples)) @@ -397,11 +478,17 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, np.square(v, out=v) # Creation of FDataGrid in the corresponding domain - data_matrix = scipy.integrate.cumtrapz(v, dx=1. / n_features, initial=0, - axis=0) + data_matrix = scipy.integrate.cumtrapz( + v, + dx=1 / n_features, + initial=0, + axis=0, + ) warping = FDataGrid(data_matrix.T, grid_points=time[:, 0]) warping = normalize_warping(warping, domain_range=(start, stop)) - warping.interpolation = SplineInterpolation(interpolation_order=3, - monotone=True) + warping.interpolation = SplineInterpolation( + interpolation_order=3, + monotone=True, + ) return warping diff --git a/skfda/exploratory/depth/_depth.py b/skfda/exploratory/depth/_depth.py index 04e188413..62950b765 100644 --- a/skfda/exploratory/depth/_depth.py +++ b/skfda/exploratory/depth/_depth.py @@ -1,23 +1,25 @@ -"""Depth Measures Module. +""" +Depth Measures Module. This module includes different methods to order functional data, -from the center (larger values) outwards(smaller ones).""" +from the center (larger values) outwards(smaller ones). -import itertools +""" +from __future__ import annotations -import scipy.integrate +import itertools +from typing import Optional import numpy as np -from . import multivariate -from .multivariate import Depth - +import scipy.integrate -__author__ = "Amanda Hernando Bernabé" -__email__ = "amanda.hernando@estudiante.uam.es" +from ... import FDataGrid +from . import multivariate +from .multivariate import Depth, _UnivariateFraimanMuniz -class IntegratedDepth(Depth): +class IntegratedDepth(Depth[FDataGrid]): r""" Functional depth as the integral of a multivariate depth. @@ -29,7 +31,6 @@ class IntegratedDepth(Depth): D(x) = 1 - \left\lvert \frac{1}{2}- F(x)\right\rvert Examples: - >>> import skfda >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], @@ -43,48 +44,71 @@ class IntegratedDepth(Depth): array([ 0.5 , 0.75 , 0.925, 0.875]) References: - Fraiman, R., & Muniz, G. (2001). Trimmed means for functional data. Test, 10(2), 419–440. https://doi.org/10.1007/BF02595706 """ - def __init__(self, *, - multivariate_depth=multivariate._UnivariateFraimanMuniz()): + def __init__( + self, + *, + multivariate_depth: Optional[Depth[np.ndarray]] = None, + ) -> None: self.multivariate_depth = multivariate_depth - def fit(self, X, y=None): + def fit( # noqa: D102 + self, + X: FDataGrid, + y: None = None, + ) -> IntegratedDepth: + + self.multivariate_depth_: Depth[np.ndarray] + + if self.multivariate_depth is None: + self.multivariate_depth_ = _UnivariateFraimanMuniz() + else: + self.multivariate_depth_ = self.multivariate_depth self._domain_range = X.domain_range self._grid_points = X.grid_points - self.multivariate_depth.fit(X.data_matrix) + self.multivariate_depth_.fit(X.data_matrix) return self - def predict(self, X): + def predict(self, X: FDataGrid) -> np.ndarray: # noqa: D102 - pointwise_depth = self.multivariate_depth.predict(X.data_matrix) + pointwise_depth = self.multivariate_depth_.predict(X.data_matrix) - interval_len = (self._domain_range[0][1] - - self._domain_range[0][0]) + interval_len = ( + self._domain_range[0][1] + - self._domain_range[0][0] + ) integrand = pointwise_depth for d, s in zip(X.domain_range, X.grid_points): - integrand = scipy.integrate.simps(integrand, - x=s, - axis=1) + integrand = scipy.integrate.simps( + integrand, + x=s, + axis=1, + ) interval_len = d[1] - d[0] integrand /= interval_len return integrand - @property - def max(self): + @property # noqa: WPS125 + def max(self) -> float: # noqa: WPS125 + if self.multivariate_depth is None: + return 1 + return self.multivariate_depth.max - @property - def min(self): + @property # noqa: WPS125 + def min(self) -> float: # noqa: WPS125 + if self.multivariate_depth is None: + return 1 / 2 + return self.multivariate_depth.min @@ -99,7 +123,6 @@ class ModifiedBandDepth(IntegratedDepth): determine the bands. Examples: - >>> import skfda >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], @@ -114,18 +137,17 @@ class ModifiedBandDepth(IntegratedDepth): array([ 0.5 , 0.83, 0.73, 0.67]) References: - López-Pintado, S., & Romo, J. (2009). On the Concept of Depth for Functional Data. Journal of the American Statistical Association, 104(486), 718–734. https://doi.org/10.1198/jasa.2009.0108 """ - def __init__(self): + def __init__(self) -> None: super().__init__(multivariate_depth=multivariate.SimplicialDepth()) -class BandDepth(Depth): +class BandDepth(Depth[FDataGrid]): r""" Implementation of Band Depth for functional data. @@ -136,7 +158,6 @@ class BandDepth(Depth): hyperplanes determine the bands. Examples: - >>> import skfda >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], @@ -150,7 +171,6 @@ class BandDepth(Depth): array([ 0.5 , 0.83333333, 0.5 , 0.5 ]) References: - López-Pintado, S., & Romo, J. (2009). On the Concept of Depth for Functional Data. Journal of the American Statistical Association, 104(486), 718–734. @@ -158,31 +178,38 @@ class BandDepth(Depth): """ - def fit(self, X, y=None): + def fit(self, X: FDataGrid, y: None = None) -> BandDepth: # noqa: D102 if X.dim_codomain != 1: - raise NotImplementedError("Band depth not implemented for vector " - "valued functions") + raise NotImplementedError( + "Band depth not implemented for vector valued functions", + ) self._distribution = X return self - def predict(self, X): + def predict(self, X: FDataGrid) -> np.ndarray: # noqa: D102 num_in = 0 n_total = 0 for f1, f2 in itertools.combinations(self._distribution, 2): - between_range_1 = (f1.data_matrix <= X.data_matrix) & ( - X.data_matrix <= f2.data_matrix) + between_range_1 = ( + (f1.data_matrix <= X.data_matrix) + & (X.data_matrix <= f2.data_matrix) + ) - between_range_2 = (f2.data_matrix <= X.data_matrix) & ( - X.data_matrix <= f1.data_matrix) + between_range_2 = ( + (f2.data_matrix <= X.data_matrix) + & (X.data_matrix <= f1.data_matrix) + ) between_range = between_range_1 | between_range_2 - num_in += np.all(between_range, - axis=tuple(range(1, X.data_matrix.ndim))) + num_in += np.all( + between_range, + axis=tuple(range(1, X.data_matrix.ndim)), + ) n_total += 1 return num_in / n_total diff --git a/skfda/exploratory/depth/multivariate.py b/skfda/exploratory/depth/multivariate.py index 3930526f2..f7c1d5081 100644 --- a/skfda/exploratory/depth/multivariate.py +++ b/skfda/exploratory/depth/multivariate.py @@ -1,76 +1,101 @@ +"""Depth and outlyingness ABCs and implementations for multivariate data.""" + +from __future__ import annotations + import abc import math -from scipy.special import comb +from typing import Generic, Optional, TypeVar +import numpy as np import scipy.stats import sklearn +from scipy.special import comb +from typing_extensions import Literal -import numpy as np - +T = TypeVar("T", contravariant=True) +SelfType = TypeVar("SelfType") +_Side = Literal["left", "right"] -class _DepthOrOutlyingness(abc.ABC, sklearn.base.BaseEstimator): - """ - Abstract class representing a depth or outlyingness function. - """ +class _DepthOrOutlyingness( + abc.ABC, + sklearn.base.BaseEstimator, # type: ignore + Generic[T], +): + """Abstract class representing a depth or outlyingness function.""" - def fit(self, X, y=None): + def fit(self: SelfType, X: T, y: None = None) -> SelfType: """ Learn the distribution from the observations. Args: X: Functional dataset from which the distribution of the data is - inferred. + inferred. y: Unused. Kept only for convention. Returns: - self: Fitted estimator. + Fitted estimator. """ return self @abc.abstractmethod - def predict(self, X): + def predict(self, X: T) -> np.ndarray: """ Compute the depth or outlyingness inside the learned distribution. Args: X: Points whose depth is going to be evaluated. + Returns: + Depth of each observation. + """ pass - def fit_predict(self, X, y=None): + def fit_predict(self, X: T, y: None = None) -> np.ndarray: """ - Compute the depth or outlyingness of each observation with respect to - the whole dataset. + Compute the depth or outlyingness of each observation. + + This computation is done with respect to the whole dataset. Args: X: Dataset. y: Unused. Kept only for convention. + Returns: + Depth of each observation. + """ return self.fit(X).predict(X) - def __call__(self, X, *, distribution=None): + def __call__( + self, + X: T, + *, + distribution: Optional[T] = None, + ) -> np.ndarray: """ - Allows the depth or outlyingness to be used as a function. + Allow the depth or outlyingness to be used as a function. Args: X: Points whose depth is going to be evaluated. distribution: Functional dataset from which the distribution of the data is inferred. If ``None`` it is the same as ``X``. + Returns: + Depth of each observation. + """ copy = sklearn.base.clone(self) if distribution is None: return copy.fit_predict(X) - else: - return copy.fit(distribution).predict(X) - @property - def max(self): + return copy.fit(distribution).predict(X) + + @property # noqa: WPS125 + def max(self) -> float: # noqa: WPS125 """ Maximum (or supremum if there is no maximum) of the possibly predicted values. @@ -78,8 +103,8 @@ def max(self): """ return 1 - @property - def min(self): + @property # noqa: WPS125 + def min(self) -> float: # noqa: WPS125 """ Minimum (or infimum if there is no maximum) of the possibly predicted values. @@ -88,41 +113,41 @@ def min(self): return 0 -class Depth(_DepthOrOutlyingness): - """ - Abstract class representing a depth function. - - """ - pass - - -class Outlyingness(_DepthOrOutlyingness): - """ - Abstract class representing an outlyingness function. +class Depth(_DepthOrOutlyingness[T]): + """Abstract class representing a depth function.""" - """ - pass +class Outlyingness(_DepthOrOutlyingness[T]): + """Abstract class representing an outlyingness function.""" -def _searchsorted_one_dim(array, values, *, side='left'): - searched_index = np.searchsorted(array, values, side=side) - return searched_index +def _searchsorted_one_dim( + array: np.ndarray, + values: np.ndarray, + *, + side: _Side = 'left', +) -> np.ndarray: + return np.searchsorted(array, values, side=side) _searchsorted_vectorized = np.vectorize( _searchsorted_one_dim, signature='(n),(m),()->(m)', - excluded='side') + excluded='side', +) -def _searchsorted_ordered(array, values, *, side='left'): +def _searchsorted_ordered( + array: np.ndarray, + values: np.ndarray, + *, + side: _Side = 'left', +) -> np.ndarray: return _searchsorted_vectorized(array, values, side=side) -def _cumulative_distribution(column): - """Calculates the cumulative distribution function of the values passed to - the function and evaluates it at each point. +def _cumulative_distribution(column: np.ndarray) -> np.ndarray: + """Calculate the cumulative distribution function at each point. Args: column (numpy.darray): Array containing the values over which the @@ -137,11 +162,14 @@ def _cumulative_distribution(column): array([ 0.4, 0.9, 1. , 0.4, 0.6, 0.6, 0.9, 0.4, 0.4, 0.7]) """ - return _searchsorted_ordered(np.sort(column), column, - side='right') / len(column) + return _searchsorted_ordered( + np.sort(column), + column, + side='right', + ) / len(column) -class _UnivariateFraimanMuniz(Depth): +class _UnivariateFraimanMuniz(Depth[np.ndarray]): r""" Univariate depth used to compute the Fraiman an Muniz depth. @@ -157,24 +185,26 @@ class _UnivariateFraimanMuniz(Depth): """ - def fit(self, X, y=None): + def fit(self: SelfType, X: np.ndarray, y: None = None) -> SelfType: self._sorted_values = np.sort(X, axis=0) return self - def predict(self, X): + def predict(self, X: np.ndarray) -> np.ndarray: cum_dist = _searchsorted_ordered( np.moveaxis(self._sorted_values, 0, -1), - np.moveaxis(X, 0, -1), side='right') / len(self._sorted_values) + np.moveaxis(X, 0, -1), + side='right', + ) / len(self._sorted_values) assert cum_dist.shape[-2] == 1 return 1 - np.abs(0.5 - np.moveaxis(cum_dist, -1, 0)[..., 0]) - @property - def min(self): + @property # noqa: WPS125 + def min(self) -> float: # noqa: WPS125 return 1 / 2 -class SimplicialDepth(Depth): +class SimplicialDepth(Depth[np.ndarray]): r""" Simplicial depth. @@ -183,38 +213,46 @@ class SimplicialDepth(Depth): :math:`p + 1` points sampled from :math:`F` contains :math:`x`. References: - Liu, R. Y. (1990). On a Notion of Data Depth Based on Random Simplices. The Annals of Statistics, 18(1), 405–414. """ - def fit(self, X, y=None): + def fit( # noqa: D102 + self, + X: np.ndarray, + y: None = None, + ) -> SimplicialDepth: self._dim = X.shape[-1] if self._dim == 1: self.sorted_values = np.sort(X, axis=0) else: - raise NotImplementedError("SimplicialDepth is currently only " - "implemented for one-dimensional data.") + raise NotImplementedError( + "SimplicialDepth is currently only " + "implemented for one-dimensional data.", + ) return self - def predict(self, X): + def predict(self, X: np.ndarray) -> np.ndarray: # noqa: D102 assert self._dim == X.shape[-1] if self._dim == 1: positions_left = _searchsorted_ordered( np.moveaxis(self.sorted_values, 0, -1), - np.moveaxis(X, 0, -1)) + np.moveaxis(X, 0, -1), + ) positions_left = np.moveaxis(positions_left, -1, 0)[..., 0] positions_right = _searchsorted_ordered( np.moveaxis(self.sorted_values, 0, -1), - np.moveaxis(X, 0, -1), side='right') + np.moveaxis(X, 0, -1), + side='right', + ) positions_right = np.moveaxis(positions_right, -1, 0)[..., 0] @@ -223,11 +261,13 @@ def predict(self, X): total_pairs = comb(len(self.sorted_values), 2) - return (total_pairs - comb(num_strictly_below, 2) - - comb(num_strictly_above, 2)) / total_pairs + return ( + total_pairs - comb(num_strictly_below, 2) + - comb(num_strictly_above, 2) + ) / total_pairs -class OutlyingnessBasedDepth(Depth): +class OutlyingnessBasedDepth(Depth[T]): r""" Computes depth based on an outlyingness measure. @@ -249,34 +289,37 @@ class OutlyingnessBasedDepth(Depth): outlyingness (Outlyingness): Outlyingness object. References: - Serfling, R. (2006). Depth functions in nonparametric multivariate inference. DIMACS Series in Discrete Mathematics and Theoretical Computer Science, 72, 1. """ - def __init__(self, outlyingness): + def __init__(self, outlyingness: Outlyingness[T]): self.outlyingness = outlyingness - def fit(self, X, y=None): + def fit( # noqa: D102 + self, + X: T, + y: None = None, + ) -> OutlyingnessBasedDepth[T]: self.outlyingness.fit(X) return self - def predict(self, X): + def predict(self, X: np.ndarray) -> np.ndarray: # noqa: D102 outlyingness_values = self.outlyingness.predict(X) min_val = self.outlyingness.min max_val = self.outlyingness.max - if(math.isinf(max_val)): + if math.isinf(max_val): return 1 / (1 + outlyingness_values - min_val) - else: - return 1 - (outlyingness_values - min_val) / (max_val - min_val) + + return 1 - (outlyingness_values - min_val) / (max_val - min_val) -class StahelDonohoOutlyingness(Outlyingness): +class StahelDonohoOutlyingness(Outlyingness[np.ndarray]): r""" Computes Stahel-Donoho outlyingness. @@ -290,44 +333,47 @@ class StahelDonohoOutlyingness(Outlyingness): median absolute deviation. References: - Zuo, Y., Cui, H., & He, X. (2004). On the Stahel-Donoho estimator and depth-weighted means of multivariate data. Annals of Statistics, 32(1), 167–188. https://doi.org/10.1214/aos/1079120132 """ - def fit(self, X, y=None): + def fit( # noqa: D102 + self, + X: np.ndarray, + y: None = None, + ) -> StahelDonohoOutlyingness: dim = X.shape[-1] if dim == 1: self._location = np.median(X, axis=0) - self._scale = scipy.stats.median_abs_deviation( - X, axis=0) + self._scale = scipy.stats.median_abs_deviation(X, axis=0) else: raise NotImplementedError("Only implemented for one dimension") return self - def predict(self, X): + def predict(self, X: np.ndarray) -> np.ndarray: # noqa: D102 dim = X.shape[-1] if dim == 1: # Special case, can be computed exactly - return (np.abs(X - self._location) / - self._scale)[..., 0] + return ( + np.abs(X - self._location) + / self._scale + )[..., 0] - else: - raise NotImplementedError("Only implemented for one dimension") + raise NotImplementedError("Only implemented for one dimension") - @property - def max(self): - return np.inf + @property # noqa: WPS125 + def max(self) -> float: # noqa: WPS125 + return math.inf -class ProjectionDepth(OutlyingnessBasedDepth): +class ProjectionDepth(OutlyingnessBasedDepth[np.ndarray]): r""" Computes Projection depth. @@ -338,12 +384,11 @@ class ProjectionDepth(OutlyingnessBasedDepth): :class:`StahelDonohoOutlyingness`: Stahel-Donoho outlyingness. References: - Zuo, Y., Cui, H., & He, X. (2004). On the Stahel-Donoho estimator and depth-weighted means of multivariate data. Annals of Statistics, 32(1), 167–188. https://doi.org/10.1214/aos/1079120132 """ - def __init__(self): + def __init__(self) -> None: super().__init__(outlyingness=StahelDonohoOutlyingness()) diff --git a/skfda/exploratory/outliers/__init__.py b/skfda/exploratory/outliers/__init__.py index 862e66fd9..760c34b32 100644 --- a/skfda/exploratory/outliers/__init__.py +++ b/skfda/exploratory/outliers/__init__.py @@ -3,4 +3,5 @@ directional_outlyingness_stats, ) from ._iqr import IQROutlierDetector +from ._outliergram import OutliergramOutlierDetector from .neighbors_outlier import LocalOutlierFactor diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 6b00efc0c..3276f9d4b 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -1,30 +1,36 @@ -from skfda.exploratory.depth.multivariate import ProjectionDepth -import typing +from __future__ import annotations -from numpy import linalg as la +from typing import NamedTuple, Optional, Tuple + +import numpy as np import scipy.integrate -from scipy.stats import f import scipy.stats +from numpy import linalg as la from sklearn.base import BaseEstimator, OutlierMixin from sklearn.covariance import MinCovDet -import numpy as np +from skfda.exploratory.depth.multivariate import Depth, ProjectionDepth from ... import FDataGrid +from ..._utils import RandomStateLike +from ...representation._typing import NDArrayFloat, NDArrayInt -class DirectionalOutlyingnessStats(typing.NamedTuple): - directional_outlyingness: np.ndarray - functional_directional_outlyingness: np.ndarray - mean_directional_outlyingness: np.ndarray - variation_directional_outlyingness: np.ndarray +class DirectionalOutlyingnessStats(NamedTuple): + directional_outlyingness: NDArrayFloat + functional_directional_outlyingness: NDArrayFloat + mean_directional_outlyingness: NDArrayFloat + variation_directional_outlyingness: NDArrayFloat def directional_outlyingness_stats( - fdatagrid: FDataGrid, *, - multivariate_depth=ProjectionDepth(), - pointwise_weights=None) -> DirectionalOutlyingnessStats: - r"""Computes the directional outlyingness of the functional data. + fdatagrid: FDataGrid, + *, + multivariate_depth: Optional[Depth[NDArrayFloat]] = None, + pointwise_weights: Optional[NDArrayFloat] = None, +) -> DirectionalOutlyingnessStats: + r""" + Compute the directional outlyingness of the functional data. Furthermore, it calculates functional, mean and the variational directional outlyingness of the samples in the data set, which are also @@ -150,17 +156,27 @@ def directional_outlyingness_stats( if fdatagrid.dim_domain > 1: raise NotImplementedError("Only support 1 dimension on the domain.") - if (pointwise_weights is not None and - (len(pointwise_weights) != len(fdatagrid.grid_points[0]) or - pointwise_weights.sum() != 1)): + if multivariate_depth is None: + multivariate_depth = ProjectionDepth() + + if ( + pointwise_weights is not None + and ( + len(pointwise_weights) != len(fdatagrid.grid_points[0]) + or pointwise_weights.sum() != 1 + ) + ): raise ValueError( "There must be a weight in pointwise_weights for each recorded " - "time point and altogether must integrate to 1.") + "time point and altogether must integrate to 1.", + ) if pointwise_weights is None: pointwise_weights = np.ones( - len(fdatagrid.grid_points[0])) / ( - fdatagrid.domain_range[0][1] - fdatagrid.domain_range[0][0]) + len(fdatagrid.grid_points[0]), + ) / ( + fdatagrid.domain_range[0][1] - fdatagrid.domain_range[0][0] + ) depth_pointwise = multivariate_depth(fdatagrid.data_matrix) assert depth_pointwise.shape == fdatagrid.data_matrix.shape[:-1] @@ -169,7 +185,9 @@ def directional_outlyingness_stats( # v(t) = {X(t) − Z(t)}/|| X(t) − Z(t) || median_index = np.argmax(depth_pointwise, axis=0) pointwise_median = fdatagrid.data_matrix[ - median_index, range(fdatagrid.data_matrix.shape[1])] + median_index, + range(fdatagrid.data_matrix.shape[1]), + ] assert pointwise_median.shape == fdatagrid.data_matrix.shape[1:] v = fdatagrid.data_matrix - pointwise_median assert v.shape == fdatagrid.data_matrix.shape @@ -185,37 +203,53 @@ def directional_outlyingness_stats( dir_outlyingness = (1 / depth_pointwise[..., np.newaxis] - 1) * v_unitary # Calculation mean directional outlyingness - weighted_dir_outlyingness = (dir_outlyingness - * pointwise_weights[:, np.newaxis]) + weighted_dir_outlyingness = ( + dir_outlyingness * pointwise_weights[:, np.newaxis] + ) assert weighted_dir_outlyingness.shape == dir_outlyingness.shape - mean_dir_outlyingness = scipy.integrate.simps(weighted_dir_outlyingness, - fdatagrid.grid_points[0], - axis=1) + mean_dir_outlyingness = scipy.integrate.simps( + weighted_dir_outlyingness, + fdatagrid.grid_points[0], + axis=1, + ) assert mean_dir_outlyingness.shape == ( - fdatagrid.n_samples, fdatagrid.dim_codomain) + fdatagrid.n_samples, + fdatagrid.dim_codomain, + ) # Calculation variation directional outlyingness - norm = np.square(la.norm(dir_outlyingness - - mean_dir_outlyingness[:, np.newaxis, :], axis=-1)) + norm = np.square(la.norm( + dir_outlyingness + - mean_dir_outlyingness[:, np.newaxis, :], + axis=-1, + )) weighted_norm = norm * pointwise_weights variation_dir_outlyingness = scipy.integrate.simps( - weighted_norm, fdatagrid.grid_points[0], - axis=1) + weighted_norm, + fdatagrid.grid_points[0], + axis=1, + ) assert variation_dir_outlyingness.shape == (fdatagrid.n_samples,) - functional_dir_outlyingness = (np.square(la.norm(mean_dir_outlyingness)) - + variation_dir_outlyingness) + functional_dir_outlyingness = ( + np.square(la.norm(mean_dir_outlyingness)) + + variation_dir_outlyingness + ) assert functional_dir_outlyingness.shape == (fdatagrid.n_samples,) return DirectionalOutlyingnessStats( directional_outlyingness=dir_outlyingness, functional_directional_outlyingness=functional_dir_outlyingness, mean_directional_outlyingness=mean_dir_outlyingness, - variation_directional_outlyingness=variation_dir_outlyingness) + variation_directional_outlyingness=variation_dir_outlyingness, + ) -class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): +class DirectionalOutlierDetector( + BaseEstimator, # type: ignore + OutlierMixin, # type: ignore +): r"""Outlier detector using directional outlyingness. Considering :math:`\mathbf{Y} = \left(\mathbf{MO}^T, VO\right)^T`, the @@ -303,14 +337,17 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): """ def __init__( - self, *, multivariate_depth=ProjectionDepth(), - pointwise_weights=None, - assume_centered=False, - support_fraction=None, - num_resamples=1000, - random_state=0, - alpha=0.993, - _force_asymptotic=False): + self, + *, + multivariate_depth: Optional[Depth[NDArrayFloat]] = None, + pointwise_weights: Optional[NDArrayFloat] = None, + assume_centered: bool = False, + support_fraction: Optional[float] = None, + num_resamples: int = 1000, + random_state: RandomStateLike = 0, + alpha: float = 0.993, + _force_asymptotic: bool = False, + ) -> None: self.multivariate_depth = multivariate_depth self.pointwise_weights = pointwise_weights self.assume_centered = assume_centered @@ -320,21 +357,30 @@ def __init__( self.alpha = alpha self._force_asymptotic = _force_asymptotic - def _compute_points(self, X): + def _compute_points(self, X: FDataGrid) -> NDArrayFloat: + multivariate_depth = self.multivariate_depth + if multivariate_depth is None: + multivariate_depth = ProjectionDepth() + # The depths of the samples are calculated giving them an ordering. *_, mean_dir_outl, variation_dir_outl = directional_outlyingness_stats( X, - multivariate_depth=self.multivariate_depth, + multivariate_depth=multivariate_depth, pointwise_weights=self.pointwise_weights) - points = np.concatenate((mean_dir_outl, - variation_dir_outl[:, np.newaxis]), axis=1) + points = np.concatenate( + (mean_dir_outl, variation_dir_outl[:, np.newaxis]), + axis=1, + ) return points - def _parameters_asymptotic(self, sample_size, dimension): + def _parameters_asymptotic( + self, + sample_size: int, + dimension: int, + ) -> Tuple[float, float]: """Return the scaling and cutoff parameters via asymptotic formula.""" - n = sample_size p = dimension @@ -342,10 +388,16 @@ def _parameters_asymptotic(self, sample_size, dimension): # c estimation xi_left = scipy.stats.chi2.rvs( - size=self.num_resamples, df=p + 2, random_state=self.random_state_) + size=self.num_resamples, + df=p + 2, + random_state=self.random_state_, + ) xi_right = scipy.stats.ncx2.rvs( - size=self.num_resamples, df=p, nc=h / n, - random_state=self.random_state_) + size=self.num_resamples, + df=p, + nc=h / n, + random_state=self.random_state_, + ) c_numerator = np.sum(xi_left < xi_right) / self.num_resamples c_denominator = h / n @@ -364,20 +416,28 @@ def _parameters_asymptotic(self, sample_size, dimension): c4 = 3 * c3 b1 = (c_alpha * (c3 - c4)) / (1 - alpha) - b2 = (0.5 + c_alpha / (1 - alpha) * - (c3 - q_alpha / p * (c2 + (1 - alpha) / 2))) - - v1 = ((1 - alpha) * b1**2 * (alpha * ( - c_alpha * q_alpha / p - 1) ** 2 - 1) - - 2 * c3 * c_alpha**2 * (3 * (b1 - p * b2)**2 - + (p + 2) * b2 * (2 * b1 - p * b2))) + b2 = ( + 0.5 + c_alpha / (1 - alpha) + * (c3 - q_alpha / p * (c2 + (1 - alpha) / 2)) + ) + + v1 = ( + (1 - alpha) * b1**2 + * (alpha * (c_alpha * q_alpha / p - 1) ** 2 - 1) + - 2 * c3 * c_alpha**2 + * ( + 3 * (b1 - p * b2)**2 + + (p + 2) * b2 * (2 * b1 - p * b2) + ) + ) v2 = n * (b1 * (b1 - p * b2) * (1 - alpha))**2 * c_alpha**2 v = v1 / v2 m_asympt = 2 / (c_alpha**2 * v) - estimated_m = (m_asympt * - np.exp(0.725 - 0.00663 * p - 0.0780 * np.log(n))) + estimated_m = ( + m_asympt * np.exp(0.725 - 0.00663 * p - 0.0780 * np.log(n)) + ) dfn = p dfd = estimated_m - p + 1 @@ -385,11 +445,15 @@ def _parameters_asymptotic(self, sample_size, dimension): # Calculation of the cutoff value and scaling factor to identify # outliers. scaling = estimated_c * dfd / estimated_m / dfn - cutoff_value = f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) + cutoff_value = scipy.stats.f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) return scaling, cutoff_value - def _parameters_numeric(self, sample_size, dimension): + def _parameters_numeric( + self, + sample_size: int, + dimension: int, + ) -> Tuple[float, float]: from . import \ _directional_outlyingness_experiment_results as experiments @@ -413,10 +477,10 @@ def _parameters_numeric(self, sample_size, dimension): if use_asympt: return self._parameters_asymptotic(sample_size, dimension) - else: - return scaling_list[key], cutoff_list[key] - def fit_predict(self, X, y=None): + return scaling_list[key], cutoff_list[key] + + def fit_predict(self, X: FDataGrid, y: None = None) -> NDArrayInt: try: self.random_state_ = np.random.RandomState(self.random_state) @@ -427,11 +491,12 @@ def fit_predict(self, X, y=None): # The square mahalanobis distances of the samples are # calulated using MCD. - self.cov_ = MinCovDet(store_precision=False, - assume_centered=self.assume_centered, - support_fraction=self.support_fraction, - random_state=self.random_state_).fit( - self.points_) + self.cov_ = MinCovDet( + store_precision=False, + assume_centered=self.assume_centered, + support_fraction=self.support_fraction, + random_state=self.random_state_, + ).fit(self.points_) # Calculation of the degrees of freedom of the F-distribution # (approximation of the tail of the distance distribution). @@ -439,13 +504,18 @@ def fit_predict(self, X, y=None): # One per dimension (mean dir out) plus one (variational dir out) dimension = X.dim_codomain + 1 if self._force_asymptotic: - self.scaling_, self.cutoff_value_ = self._parameters_asymptotic( + scaling, cutoff_value = self._parameters_asymptotic( sample_size=X.n_samples, - dimension=dimension) + dimension=dimension, + ) else: - self.scaling_, self.cutoff_value_ = self._parameters_numeric( + scaling, cutoff_value = self._parameters_numeric( sample_size=X.n_samples, - dimension=dimension) + dimension=dimension, + ) + + self.scaling_ = scaling + self.cutoff_value_ = cutoff_value rmd_2 = self.cov_.mahalanobis(self.points_) diff --git a/skfda/exploratory/outliers/_envelopes.py b/skfda/exploratory/outliers/_envelopes.py index 68c691618..22f8a3a46 100644 --- a/skfda/exploratory/outliers/_envelopes.py +++ b/skfda/exploratory/outliers/_envelopes.py @@ -1,44 +1,70 @@ +from __future__ import annotations + import math +from typing import Tuple import numpy as np +from ...representation import FDataGrid +from ...representation._typing import NDArrayBool, NDArrayFloat, NDArrayInt + -def _compute_region(fdatagrid, - indices_descending_depth, - prob): +def compute_region( + fdatagrid: FDataGrid, + indices_descending_depth: NDArrayInt, + prob: float, +) -> FDataGrid: + """Compute central region of a given quantile.""" indices_samples = indices_descending_depth[ - :math.ceil(fdatagrid.n_samples * prob)] + :math.ceil(fdatagrid.n_samples * prob) + ] return fdatagrid[indices_samples] -def _compute_envelope(region): +def compute_envelope(region: FDataGrid) -> Tuple[NDArrayFloat, NDArrayFloat]: + """Compute curves comprising a region.""" max_envelope = np.max(region.data_matrix, axis=0) min_envelope = np.min(region.data_matrix, axis=0) return min_envelope, max_envelope -def _predict_outliers(fdatagrid, non_outlying_threshold): - # A functional datum is considered an outlier if it has ANY point - # in ANY dimension outside the envelope for inliers +def predict_outliers( + fdatagrid: FDataGrid, + non_outlying_threshold: Tuple[NDArrayFloat, NDArrayFloat], +) -> NDArrayBool: + """ + Predict outliers given a threshold. + A functional datum is considered an outlier if it has ANY point + in ANY dimension outside the envelope for inliers. + + """ min_threshold, max_threshold = non_outlying_threshold or_axes = tuple(i for i in range(1, fdatagrid.data_matrix.ndim)) - below_outliers = np.any(fdatagrid.data_matrix < - min_threshold, axis=or_axes) - above_outliers = np.any(fdatagrid.data_matrix > - max_threshold, axis=or_axes) + below_outliers = np.any( + fdatagrid.data_matrix < min_threshold, + axis=or_axes, + ) + above_outliers = np.any( + fdatagrid.data_matrix > max_threshold, + axis=or_axes, + ) return below_outliers | above_outliers -def _non_outlying_threshold(central_envelope, factor): +def non_outlying_threshold( + central_envelope: Tuple[NDArrayFloat, NDArrayFloat], + factor: float, +) -> Tuple[NDArrayFloat, NDArrayFloat]: + """Compute a non outlying threshold.""" iqr = central_envelope[1] - central_envelope[0] non_outlying_threshold_max = central_envelope[1] + iqr * factor non_outlying_threshold_min = central_envelope[0] - iqr * factor - non_outlying_threshold = (non_outlying_threshold_min, - non_outlying_threshold_max) - - return non_outlying_threshold + return ( + non_outlying_threshold_min, + non_outlying_threshold_max, + ) diff --git a/skfda/exploratory/outliers/_iqr.py b/skfda/exploratory/outliers/_iqr.py index 98e74f43a..5f13518a1 100644 --- a/skfda/exploratory/outliers/_iqr.py +++ b/skfda/exploratory/outliers/_iqr.py @@ -1,10 +1,19 @@ +from __future__ import annotations + +from typing import Optional + +import numpy as np from sklearn.base import BaseEstimator, OutlierMixin +from ...representation import FDataGrid +from ..depth import Depth, ModifiedBandDepth from . import _envelopes -from ..depth import ModifiedBandDepth -class IQROutlierDetector(BaseEstimator, OutlierMixin): +class IQROutlierDetector( + BaseEstimator, # type: ignore + OutlierMixin, # type: ignore +): r"""Outlier detector using the interquartile range. Detects as outliers functions that have one or more points outside @@ -32,28 +41,41 @@ class IQROutlierDetector(BaseEstimator, OutlierMixin): """ - def __init__(self, *, depth_method=ModifiedBandDepth(), factor=1.5): + def __init__( + self, + *, + depth_method: Optional[Depth[FDataGrid]] = None, + factor: float = 1.5, + ) -> None: self.depth_method = depth_method self.factor = factor - def fit(self, X, y=None): - depth = self.depth_method(X) + def fit(self, X: FDataGrid, y: None = None) -> IQROutlierDetector: + + depth_method = ( + self.depth_method + if self.depth_method is not None + else ModifiedBandDepth() + ) + depth = depth_method(X) indices_descending_depth = (-depth).argsort(axis=0) # Central region and envelope must be computed for outlier detection - central_region = _envelopes._compute_region( + central_region = _envelopes.compute_region( X, indices_descending_depth, 0.5) - self._central_envelope = _envelopes._compute_envelope(central_region) + self._central_envelope = _envelopes.compute_envelope(central_region) # Non-outlying envelope - self.non_outlying_threshold_ = _envelopes._non_outlying_threshold( + self.non_outlying_threshold_ = _envelopes.non_outlying_threshold( self._central_envelope, self.factor) return self - def predict(self, X): - outliers = _envelopes._predict_outliers( - X, self.non_outlying_threshold_) + def predict(self, X: FDataGrid) -> np.ndarray: + outliers = _envelopes.predict_outliers( + X, + self.non_outlying_threshold_, + ) # Predict as scikit-learn outlier detectors predicted = ~outliers + outliers * -1 diff --git a/skfda/exploratory/outliers/_outliergram.py b/skfda/exploratory/outliers/_outliergram.py new file mode 100644 index 000000000..a8dc80275 --- /dev/null +++ b/skfda/exploratory/outliers/_outliergram.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import numpy as np +from sklearn.base import BaseEstimator, OutlierMixin + +from ...representation import FDataGrid +from ..depth._depth import ModifiedBandDepth +from ..stats import modified_epigraph_index + + +class OutliergramOutlierDetector( + BaseEstimator, # type: ignore + OutlierMixin, # type: ignore +): + r"""Outlier detector using the relation between MEI and MBD. + + Detects as outliers functions that have the vertical distance to the + outliergram parabola greater than ``factor`` times the interquartile + range (IQR) of those distances plus the third quartile. This corresponds + to the points selected as outliers by the outliergram. + + Parameters: + factor (float): The number of times the IQR is multiplied. + + Example: + Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. + + >>> import skfda + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 1, -1, 3, 2, 1], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 5, 5, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[0, 0, 0, 0, 0, 0], + ... [1, 1, 1, 1, 1, 1], + ... [2, 2, 2, 2, 2, 2], + ... [3, 3, 3, 3, 3, 3], + ... [9, 9, 9, -1, -1, -1], + ... [4, 4, 4, 4, 4, 4], + ... [5, 5, 5, 5, 5, 5], + ... [6, 6, 6, 6, 6, 6], + ... [7, 7, 7, 7, 7, 7], + ... [8, 8, 8, 8, 8, 8]] + >>> grid_points = [0, 2, 4, 6, 8, 10] + >>> fd = skfda.FDataGrid(data_matrix, grid_points) + >>> out_detector = OutliergramOutlierDetector() + >>> out_detector.fit_predict(fd) + array([ 1, 1, 1, 1, -1, 1, 1, 1, 1, 1]) + + """ + + def __init__(self, *, factor: float = 1.5) -> None: + self.factor = factor + + def _compute_parabola(self, X: FDataGrid) -> np.ndarray: + """Compute the parabola in which pairs (mei, mbd) should lie.""" + a_0 = -2 / (X.n_samples * (X.n_samples - 1)) + a_1 = (2 * (X.n_samples + 1)) / (X.n_samples - 1) + a_2 = a_0 + + return ( + a_0 + a_1 * self.mei_ + + X.n_samples**2 * a_2 * self.mei_**2 + ) + + def _compute_maximum_inlier_distance(self, distances: np.ndarray) -> float: + """Compute the distance above which data are considered outliers.""" + first_quartile = np.percentile(distances, 25) # noqa: WPS432 + third_quartile = np.percentile(distances, 75) # noqa: WPS432 + iqr = third_quartile - first_quartile + return third_quartile + self.factor * iqr + + def fit(self, X: FDataGrid, y: None = None) -> OutliergramOutlierDetector: + self.mbd_ = ModifiedBandDepth()(X) + self.mei_ = modified_epigraph_index(X) + self.parabola_ = self._compute_parabola(X) + self.distances_ = self.parabola_ - self.mbd_ + self.max_inlier_distance_ = self._compute_maximum_inlier_distance( + self.distances_, + ) + + return self + + def fit_predict(self, X: FDataGrid, y: None = None) -> np.ndarray: + self.fit(X, y) + + outliers = self.distances_ > self.max_inlier_distance_ + + # Predict as scikit-learn outlier detectors + predicted = ~outliers + outliers * -1 + + return predicted diff --git a/skfda/exploratory/outliers/neighbors_outlier.py b/skfda/exploratory/outliers/neighbors_outlier.py index d9fd591a0..ce12611d9 100644 --- a/skfda/exploratory/outliers/neighbors_outlier.py +++ b/skfda/exploratory/outliers/neighbors_outlier.py @@ -1,7 +1,7 @@ """Neighbors outlier detection methods.""" from sklearn.base import OutlierMixin -from ...misc.metrics import lp_distance +from ...misc.metrics import l2_distance from ...ml._neighbors_base import ( KNeighborsMixin, NeighborsBase, @@ -51,7 +51,7 @@ class LocalOutlierFactor(NeighborsBase, NeighborsMixin, KNeighborsMixin, required to store the tree. The optimal value depends on the nature of the problem. metric : string or callable, (default - :func:`lp_distance `) + :func:`l2_distance `) the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. @@ -286,7 +286,7 @@ def fit_predict(self, X, y=None): if not self.multivariate_metric: # Constructs sklearn metric to manage vector if self.metric == 'l2': - metric = lp_distance + metric = l2_distance else: metric = self.metric sklearn_metric = _to_multivariate_metric(metric, diff --git a/skfda/exploratory/stats/__init__.py b/skfda/exploratory/stats/__init__.py index e795b513c..175abf8b3 100644 --- a/skfda/exploratory/stats/__init__.py +++ b/skfda/exploratory/stats/__init__.py @@ -1,2 +1,10 @@ -from ._stats import (mean, var, gmean, cov, - depth_based_median, trim_mean, geometric_median) +from ._stats import ( + cov, + depth_based_median, + geometric_median, + gmean, + mean, + modified_epigraph_index, + trim_mean, + var, +) diff --git a/skfda/exploratory/stats/_stats.py b/skfda/exploratory/stats/_stats.py index 32871ffb9..bc946703a 100644 --- a/skfda/exploratory/stats/_stats.py +++ b/skfda/exploratory/stats/_stats.py @@ -1,11 +1,13 @@ """Functional data descriptive statistics.""" from builtins import isinstance -from typing import Callable, Optional, TypeVar, Union +from typing import Optional, TypeVar, Union import numpy as np +from scipy import integrate +from scipy.stats import rankdata -from ...misc.metrics import l2_distance, lp_norm +from ...misc.metrics import Metric, l2_distance, l2_norm from ...representation import FData, FDataGrid from ..depth import Depth, ModifiedBandDepth @@ -72,6 +74,44 @@ def cov(X: FData) -> FDataGrid: return X.cov() +def modified_epigraph_index(X: FDataGrid) -> np.ndarray: + """ + Calculate the Modified Epigraph Index of a FDataGrid. + + The MEI represents the mean time a curve stays below other curve. + In this case we will calculate the MEI for each curve in relation + with all the other curves of our dataset. + + """ + interval_len = ( + X.domain_range[0][1] + - X.domain_range[0][0] + ) + + # Array containing at each point the number of curves + # are above it. + num_functions_above = rankdata( + -X.data_matrix, + method='max', + axis=0, + ) - 1 + + integrand = num_functions_above + + for d, s in zip(X.domain_range, X.grid_points): + integrand = integrate.simps( + integrand, + x=s, + axis=1, + ) + interval_len = d[1] - d[0] + integrand /= interval_len + + integrand /= X.n_samples + + return integrand.flatten() + + def depth_based_median( X: FDataGrid, depth_method: Optional[Depth] = None, @@ -121,7 +161,7 @@ def geometric_median( X: T, *, tol: float = 1.e-8, - metric: Callable[[T, T], np.ndarray] = l2_distance, + metric: Metric[T] = l2_distance, ) -> T: r"""Compute the geometric median. @@ -158,9 +198,9 @@ def geometric_median( :func:`depth_based_median` References: - Gervini, D. (2008). Robust functional estimation using the median and - spherical principal components. Biometrika, 95(3), 587–600. - https://doi.org/10.1093/biomet/asn031 + .. footbibliography:: + + gervini_2008_estimation """ weights = np.full(len(X), 1 / len(X)) @@ -177,7 +217,7 @@ def geometric_median( median_new = _weighted_average(X, weights_new) - if lp_norm(median_new - median) < tol: + if l2_norm(median_new - median) < tol: return median_new distances = metric(X, median_new) @@ -186,10 +226,10 @@ def geometric_median( def trim_mean( - X: FDataGrid, + X: F, proportiontocut: float, *, - depth_method: Optional[Depth] = None, + depth_method: Optional[Depth[F]] = None, ) -> FDataGrid: """Compute the trimmed means based on a depth measure. diff --git a/skfda/exploratory/visualization/__init__.py b/skfda/exploratory/visualization/__init__.py index 838c653f2..05bad49a9 100644 --- a/skfda/exploratory/visualization/__init__.py +++ b/skfda/exploratory/visualization/__init__.py @@ -1,4 +1,11 @@ +"""Initialization module of visualization folder.""" + from . import clustering, representation +from ._baseplot import BasePlot from ._boxplot import Boxplot, SurfaceBoxplot +from ._ddplot import DDPlot from ._magnitude_shape_plot import MagnitudeShapePlot -from .fpca import plot_fpca_perturbation_graphs +from ._multiple_display import MultipleDisplay +from ._outliergram import Outliergram +from ._parametric_plot import ParametricPlot +from .fpca import FPCAPlot diff --git a/skfda/exploratory/visualization/_baseplot.py b/skfda/exploratory/visualization/_baseplot.py new file mode 100644 index 000000000..56eeab59b --- /dev/null +++ b/skfda/exploratory/visualization/_baseplot.py @@ -0,0 +1,119 @@ +"""BasePlot Module. + +This module contains the abstract class of which inherit all +the visualization modules, containing the basic functionality +common to all of them. +""" + +from abc import ABC, abstractmethod +from typing import Optional, Sequence, Tuple, Union + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from ._utils import _figure_to_svg, _get_figure_and_axes, _set_figure_layout + + +class BasePlot(ABC): + """ + BasePlot class. + + Attributes: + artists: List of Artist objects corresponding + to every instance of our plot. They will be used to modify + the visualization with interactivity and widgets. + fig: Figure over with the graphs are plotted. + axes: Sequence of axes where the graphs are plotted. + """ + + @abstractmethod + def __init__( + self, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + ) -> None: + self.artists: Optional[np.ndarray] = None + self.chart = chart + self.fig = fig + self.axes = axes + self.n_rows = n_rows + self.n_cols = n_cols + + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + pass + + def plot( + self, + ) -> Figure: + """ + Abstract method used to plot the object and its data. + + Returns: + Figure: figure object in which the displays and + widgets will be plotted. + """ + fig = getattr(self, "fig_", None) + axes = getattr(self, "axes_", None) + + if fig is None: + fig, axes = self._set_figure_and_axes( + self.chart, + fig=self.fig, + axes=self.axes, + ) + + self._plot(fig, axes) + return fig + + @property + def dim(self) -> int: + """Get the number of dimensions for this plot.""" + return 2 + + @property + def n_subplots(self) -> int: + """Get the number of subplots that this plot uses.""" + return 1 + + @property + def n_samples(self) -> Optional[int]: + """Get the number of instances that will be used for interactivity.""" + return None + + def _set_figure_and_axes( + self, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, + ) -> Tuple[Figure, Sequence[Axes]]: + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout( + fig=fig, + axes=axes, + dim=self.dim, + n_axes=self.n_subplots, + n_rows=self.n_rows, + n_cols=self.n_cols, + ) + + self.fig_ = fig + self.axes_ = axes + + return fig, axes + + def _repr_svg_(self) -> str: + """Automatically represents the object as an svg when calling it.""" + self.fig = self.plot() + plt.close(self.fig) + return _figure_to_svg(self.fig) diff --git a/skfda/exploratory/visualization/_boxplot.py b/skfda/exploratory/visualization/_boxplot.py index 191f888a7..73c6161ad 100644 --- a/skfda/exploratory/visualization/_boxplot.py +++ b/skfda/exploratory/visualization/_boxplot.py @@ -4,26 +4,33 @@ visualize it. """ -from abc import ABC, abstractmethod +from __future__ import annotations + import math +from abc import abstractmethod +from typing import Optional, Sequence, Tuple, Union import matplotlib - import matplotlib.pyplot as plt import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.colors import Colormap +from matplotlib.figure import Figure + +from skfda.exploratory.depth.multivariate import Depth +from ... import FData, FDataGrid +from ...representation._typing import NDArrayBool, NDArrayFloat from ..depth import ModifiedBandDepth from ..outliers import _envelopes -from ._utils import (_figure_to_svg, _get_figure_and_axes, - _set_figure_layout_for_fdata, _set_labels) - - -__author__ = "Amanda Hernando Bernabé" -__email__ = "amanda.hernando@estudiante.uam.es" +from ._baseplot import BasePlot +from ._utils import _set_labels -class FDataBoxplot(ABC): - """Abstract class inherited by the Boxplot and SurfaceBoxplot classes. +class FDataBoxplot(BasePlot): + """ + Abstract class inherited by the Boxplot and SurfaceBoxplot classes. It the data of the functional boxplot or surface boxplot of a FDataGrid object, depending on the dimensions of the :term:`domain`, 1 or 2 @@ -34,58 +41,70 @@ class FDataBoxplot(ABC): graphical representation, obtained calling the plot method. """ + @abstractmethod - def __init__(self, factor=1.5): + def __init__( + self, + chart: Union[Figure, Axes, None] = None, + *, + factor: float = 1.5, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + ) -> None: if factor < 0: - raise ValueError("The number used to calculate the " - "outlying envelope must be positive.") + raise ValueError( + "The number used to calculate the " + "outlying envelope must be positive.", + ) + + super().__init__( + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + ) self._factor = factor @property - def factor(self): + def factor(self) -> float: return self._factor @property - def fdatagrid(self): + def fdatagrid(self) -> FDataGrid: pass @property - def median(self): + def median(self) -> NDArrayFloat: pass @property - def central_envelope(self): + def central_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: pass @property - def non_outlying_envelope(self): + def non_outlying_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: pass @property - def colormap(self): + def colormap(self) -> Colormap: return self._colormap @colormap.setter - def colormap(self, value): + def colormap(self, value: Colormap) -> None: if not isinstance(value, matplotlib.colors.LinearSegmentedColormap): - raise ValueError("colormap must be of type " - "matplotlib.colors.LinearSegmentedColormap") + raise ValueError( + "colormap must be of type " + "matplotlib.colors.LinearSegmentedColormap", + ) self._colormap = value - @abstractmethod - def plot(self, chart=None, *, fig=None, axes=None, - n_rows=None, n_cols=None): - pass - - def _repr_svg_(self): - fig = self.plot() - plt.close(fig) - - return _figure_to_svg(fig) - class Boxplot(FDataBoxplot): - r"""Representation of the functional boxplot. + r""" + Representation of the functional boxplot. Class implementing the functionl boxplot which is an informative exploratory tool for visualizing functional data, as well as its @@ -99,39 +118,44 @@ class Boxplot(FDataBoxplot): detected in a functional boxplot by the 1.5 times the 50% central region empirical rule, analogous to the rule for classical boxplots. - Args: + For more information see :footcite:ts:`sun+genton_2011_boxplots`. - fdatagrid (FDataGrid): Object containing the data. - depth_method (:ref:`depth measure `, optional): - Method used to order the data. Defaults to :func:`modified - band depth - `. - prob (list of float, optional): List with float numbers (in the - range from 1 to 0) that indicate which central regions to - represent. - Defaults to [0.5] which represents the 50% central region. - factor (double): Number used to calculate the outlying envelope. + Args: + fdatagrid: Object containing the data. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + depth_method: Method used to order the data. Defaults to + :func:`~skfda.exploratory.depth.ModifiedBandDepth`. + prob: List with float numbers (in the range from 1 to 0) that + indicate which central regions to represent. + Defaults to (0.5,) which represents the 50% central region. + factor: Number used to calculate the outlying envelope. + fig: Figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + axes: Axis over where the graphs + are plotted. If None, see param fig. + n_rows: Designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols: Designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. Attributes: - - fdatagrid (FDataGrid): Object containing the data. - median (array, (fdatagrid.dim_codomain, ngrid_points)): contains - the median/s. - central_envelope (array, (fdatagrid.dim_codomain, 2, ngrid_points)): - contains the central envelope/s. - non_outlying_envelope (array, (fdatagrid.dim_codomain, 2, - ngrid_points)): - contains the non-outlying envelope/s. - colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from - which the colors to represent the central regions are selected. - envelopes (array, (fdatagrid.dim_codomain * ncentral_regions, 2, - ngrid_points)): contains the region envelopes. - outliers (array, (fdatagrid.dim_codomain, fdatagrid.n_samples)): - contains the outliers. - barcol (string): Color of the envelopes and vertical lines. - outliercol (string): Color of the ouliers. - mediancol (string): Color of the median. - show_full_outliers (boolean): If False (the default) then only the part + fdatagrid: Object containing the data. + median: Contains the median/s. + central_envelope: Contains the central envelope/s. + non_outlying_envelope: Contains the non-outlying envelope/s. + colormap: Colormap from which the colors to represent the + central regions are selected. + envelopes: Contains the region envelopes. + outliers: Contains the outliers. + barcol: Color of the envelopes and vertical lines. + outliercol: Color of the ouliers. + mediancol: Color of the median. + show_full_outliers: If False (the default) then only the part outside the box is plotted. If True, complete outling curves are plotted. @@ -150,7 +174,6 @@ class Boxplot(FDataBoxplot): Examples: - Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. >>> from skfda import FDataGrid @@ -239,74 +262,118 @@ class Boxplot(FDataBoxplot): outliers=array([ True, False, False, True])) References: - - Sun, Y., & Genton, M. G. (2011). Functional Boxplots. Journal of - Computational and Graphical Statistics, 20(2), 316-334. - https://doi.org/10.1198/jcgs.2011.09224 - + .. footbibliography:: """ - def __init__(self, fdatagrid, depth_method=ModifiedBandDepth(), prob=[0.5], - factor=1.5): - """Initialization of the Boxplot class. + def __init__( + self, + fdatagrid: FData, + chart: Union[Figure, Axes, None] = None, + *, + depth_method: Optional[Depth[FDataGrid]] = None, + prob: Sequence[float] = (0.5,), + factor: float = 1.5, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + ): + """Initialize the Boxplot class. Args: - fdatagrid (FDataGrid): Object containing the data. - depth_method (:ref:`depth measure `, optional): - Method used to order the data. Defaults to :func:`modified - band depth + fdatagrid: Object containing the data. + depth_method: Method used to order the data. + Defaults to :func:`modified band depth `. - prob (list of float, optional): List with float numbers (in the + prob: List with float numbers (in the range from 1 to 0) that indicate which central regions to represent. Defaults to [0.5] which represents the 50% central region. - factor (double): Number used to calculate the outlying envelope. + factor: Number used to calculate the outlying envelope. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + axes: axis over where the graphs + are plotted. If None, see param fig. + n_rows: designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols: designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. """ - FDataBoxplot.__init__(self, factor) + super().__init__( + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + factor=factor, + ) if fdatagrid.dim_domain != 1: raise ValueError( - "Function only supports FDataGrid with domain dimension 1.") + "Function only supports FDataGrid with domain dimension 1.", + ) - if sorted(prob, reverse=True) != prob: + if sorted(prob, reverse=True) != list(prob): raise ValueError( - "Probabilities required to be in descending order.") + "Probabilities required to be in descending order.", + ) if min(prob) < 0 or max(prob) > 1: raise ValueError("Probabilities must be between 0 and 1.") - self._envelopes = [None] * len(prob) - + if depth_method is None: + depth_method = ModifiedBandDepth() depth = depth_method(fdatagrid) indices_descending_depth = (-depth).argsort(axis=0) # The median is the deepest curve - self._median = fdatagrid[indices_descending_depth[0] - ].data_matrix[0, ...] + median_fdata = fdatagrid[indices_descending_depth[0]] + self._median = median_fdata.data_matrix[0, ...] # Central region and envelope must be computed for outlier detection - central_region = _envelopes._compute_region( - fdatagrid, indices_descending_depth, 0.5) - self._central_envelope = _envelopes._compute_envelope(central_region) + central_region = _envelopes.compute_region( + fdatagrid, + indices_descending_depth, + 0.5, + ) + self._central_envelope = _envelopes.compute_envelope(central_region) # Non-outlying envelope - non_outlying_threshold = _envelopes._non_outlying_threshold( - self._central_envelope, factor) - predicted_outliers = _envelopes._predict_outliers( - fdatagrid, non_outlying_threshold) + non_outlying_threshold = _envelopes.non_outlying_threshold( + self._central_envelope, + factor, + ) + predicted_outliers = _envelopes.predict_outliers( + fdatagrid, + non_outlying_threshold, + ) inliers = fdatagrid[predicted_outliers == 0] - self._non_outlying_envelope = _envelopes._compute_envelope(inliers) + self._non_outlying_envelope = _envelopes.compute_envelope(inliers) # Outliers - self._outliers = _envelopes._predict_outliers( - fdatagrid, self._non_outlying_envelope) - - for i, p in enumerate(prob): - region = _envelopes._compute_region( - fdatagrid, indices_descending_depth, p) - self._envelopes[i] = _envelopes._compute_envelope(region) + self._outliers = _envelopes.predict_outliers( + fdatagrid, + self._non_outlying_envelope, + ) + + self._envelopes = [ + _envelopes.compute_envelope( + _envelopes.compute_region( + fdatagrid, + indices_descending_depth, + p, + ), + ) + for p in prob + ] self._fdatagrid = fdatagrid self._prob = prob @@ -317,65 +384,49 @@ def __init__(self, fdatagrid, depth_method=ModifiedBandDepth(), prob=[0.5], self._show_full_outliers = False @property - def fdatagrid(self): + def fdatagrid(self) -> FDataGrid: return self._fdatagrid @property - def median(self): + def median(self) -> NDArrayFloat: return self._median @property - def central_envelope(self): + def central_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: return self._central_envelope @property - def non_outlying_envelope(self): + def non_outlying_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: return self._non_outlying_envelope @property - def envelopes(self): + def envelopes(self) -> Sequence[Tuple[NDArrayFloat, NDArrayFloat]]: return self._envelopes @property - def outliers(self): + def outliers(self) -> NDArrayBool: return self._outliers @property - def show_full_outliers(self): + def show_full_outliers(self) -> bool: return self._show_full_outliers @show_full_outliers.setter - def show_full_outliers(self, boolean): + def show_full_outliers(self, boolean: bool) -> None: if not isinstance(boolean, bool): raise ValueError("show_full_outliers must be boolean type") self._show_full_outliers = boolean - def plot(self, chart=None, *, fig=None, axes=None, - n_rows=None, n_cols=None): - """Visualization of the functional boxplot of the fdatagrid - (dim_domain=1). - - Args: - fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also - None, the figure is initialized. - axes (list of axis objects, optional): axis over where the graphs - are plotted. If None, see param fig. - n_rows(int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. - n_cols(int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Only - specified if fig and ax are None. - - Returns: - fig (figure): figure object in which the graphs are plotted. + @property + def n_subplots(self) -> int: + return self.fdatagrid.dim_codomain - """ + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout_for_fdata( - self.fdatagrid, fig, axes, n_rows, n_cols) tones = np.linspace(0.1, 1.0, len(self._prob) + 1, endpoint=False)[1:] color = self.colormap(tones) @@ -386,70 +437,98 @@ def plot(self, chart=None, *, fig=None, axes=None, outliers = self.fdatagrid[self.outliers] - for m in range(self.fdatagrid.dim_codomain): + grid_points = self.fdatagrid.grid_points[0] + + for m, ax in enumerate(axes): # Outliers for o in outliers: - axes[m].plot(o.grid_points[0], - o.data_matrix[0, :, m], - color=self.outliercol, - linestyle='--', zorder=1) - - for i in range(len(self._prob)): + ax.plot( + grid_points, + o.data_matrix[0, :, m], + color=self.outliercol, + linestyle='--', + zorder=1, + ) + + for envelop, col in zip(self.envelopes, color): # central regions - axes[m].fill_between(self.fdatagrid.grid_points[0], - self.envelopes[i][0][..., m], - self.envelopes[i][1][..., m], - facecolor=color[i], zorder=var_zorder) + ax.fill_between( + grid_points, + envelop[0][..., m], + envelop[1][..., m], + facecolor=col, + zorder=var_zorder, + ) # outlying envelope - axes[m].plot(self.fdatagrid.grid_points[0], - self.non_outlying_envelope[0][..., m], - self.fdatagrid.grid_points[0], - self.non_outlying_envelope[1][..., m], - color=self.barcol, zorder=4) + ax.plot( + grid_points, + self.non_outlying_envelope[0][..., m], + grid_points, + self.non_outlying_envelope[1][..., m], + color=self.barcol, + zorder=4, + ) # central envelope - axes[m].plot(self.fdatagrid.grid_points[0], - self.central_envelope[0][..., m], - self.fdatagrid.grid_points[0], - self.central_envelope[1][..., m], - color=self.barcol, zorder=4) + ax.plot( + grid_points, + self.central_envelope[0][..., m], + grid_points, + self.central_envelope[1][..., m], + color=self.barcol, + zorder=4, + ) # vertical lines - index = math.ceil(self.fdatagrid.ncol / 2) - x = self.fdatagrid.grid_points[0][index] - axes[m].plot([x, x], - [self.non_outlying_envelope[0][..., m][index], - self.central_envelope[0][..., m][index]], - color=self.barcol, - zorder=4) - axes[m].plot([x, x], - [self.non_outlying_envelope[1][..., m][index], - self.central_envelope[1][..., m][index]], - color=self.barcol, zorder=4) + index = math.ceil(len(grid_points) / 2) + x = grid_points[index] + ax.plot( + [x, x], + [ + self.non_outlying_envelope[0][..., m][index], + self.central_envelope[0][..., m][index], + ], + color=self.barcol, + zorder=4, + ) + ax.plot( + [x, x], + [ + self.non_outlying_envelope[1][..., m][index], + self.central_envelope[1][..., m][index], + ], + color=self.barcol, + zorder=4, + ) # median sample - axes[m].plot(self.fdatagrid.grid_points[0], self.median[..., m], - color=self.mediancol, zorder=5) + ax.plot( + grid_points, + self.median[..., m], + color=self.mediancol, + zorder=5, + ) _set_labels(self.fdatagrid, fig, axes) - return fig - - def __repr__(self): + def __repr__(self) -> str: """Return repr(self).""" - return (f"Boxplot(" - f"\nFDataGrid={repr(self.fdatagrid)}," - f"\nmedian={repr(self.median)}," - f"\ncentral envelope={repr(self.central_envelope)}," - f"\nnon-outlying envelope={repr(self.non_outlying_envelope)}," - f"\nenvelopes={repr(self.envelopes)}," - f"\noutliers={repr(self.outliers)})").replace('\n', '\n ') + return ( + f"Boxplot(" + f"\nFDataGrid={repr(self.fdatagrid)}," + f"\nmedian={repr(self.median)}," + f"\ncentral envelope={repr(self.central_envelope)}," + f"\nnon-outlying envelope={repr(self.non_outlying_envelope)}," + f"\nenvelopes={repr(self.envelopes)}," + f"\noutliers={repr(self.outliers)})" + ).replace('\n', '\n ') class SurfaceBoxplot(FDataBoxplot): - r"""Representation of the surface boxplot. + r""" + Representation of the surface boxplot. Class implementing the surface boxplot. Analogously to the functional boxplot, it is an informative exploratory tool for visualizing functional @@ -460,37 +539,43 @@ class SurfaceBoxplot(FDataBoxplot): :ref:`depth measure ` for functional data, it represents the envelope of the 50% central region, the median curve, and the maximum non-outlying - envelope. + envelope :footcite:`sun+genton_2011_boxplots`. Args: - - fdatagrid (FDataGrid): Object containing the data. - method (:ref:`depth measure `, optional): Method + fdatagrid: Object containing the data. + method: Method used to order the data. Defaults to :class:`modified band depth `. - prob (list of float, optional): List with float numbers (in the + prob: List with float numbers (in the range from 1 to 0) that indicate which central regions to represent. Defaults to [0.5] which represents the 50% central region. - factor (double): Number used to calculate the outlying envelope. + factor: Number used to calculate the outlying envelope. Attributes: - - fdatagrid (FDataGrid): Object containing the data. - median (array, (fdatagrid.dim_codomain, lx, ly)): contains + fdatagrid: Object containing the data. + median: contains the median/s. - central_envelope (array, (fdatagrid.dim_codomain, 2, lx, ly)): - contains the central envelope/s. - non_outlying_envelope (array,(fdatagrid.dim_codomain, 2, lx, ly)): - contains the non-outlying envelope/s. - colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from + central_envelope: contains the central envelope/s. + non_outlying_envelope: contains the non-outlying envelope/s. + colormap: Colormap from which the colors to represent the central regions are selected. - boxcol (string): Color of the box, which includes median and central + boxcol: Color of the box, which includes median and central envelope. - outcol (string): Color of the outlying envelope. + outcol: Color of the outlying envelope. + fig: Figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + axes: Axis over where the graphs + are plotted. If None, see param fig. + n_rows: Designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols: Designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. Examples: - Function :math:`f : \mathbb{R^2}\longmapsto\mathbb{R}`. >>> from skfda import FDataGrid @@ -556,52 +641,65 @@ class SurfaceBoxplot(FDataBoxplot): [ 5. ]]]))) References: - - Sun, Y., & Genton, M. G. (2011). Functional Boxplots. Journal of - Computational and Graphical Statistics, 20(2), 316-334. - https://doi.org/10.1198/jcgs.2011.09224 + .. footbibliography:: """ - def __init__(self, fdatagrid, method=ModifiedBandDepth(), factor=1.5): - """Initialization of the functional boxplot. - - Args: - fdatagrid (FDataGrid): Object containing the data. - method (:ref:`depth measure `, optional): Method - used to order the data. Defaults to :class:`modified band depth - `. - prob (list of float, optional): List with float numbers (in the - range from 1 to 0) that indicate which central regions to - represent. - Defaults to [0.5] which represents the 50% central region. - factor (double): Number used to calculate the outlying envelope. - - """ - FDataBoxplot.__init__(self, factor) + def __init__( + self, + fdatagrid: FDataGrid, + chart: Union[Figure, Axes, None] = None, + *, + depth_method: Optional[Depth[FDataGrid]] = None, + factor: float = 1.5, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + ) -> None: + + super().__init__( + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + factor=factor, + ) if fdatagrid.dim_domain != 2: raise ValueError( - "Class only supports FDataGrid with domain dimension 2.") + "Class only supports FDataGrid with domain dimension 2.", + ) + + if depth_method is None: + depth_method = ModifiedBandDepth() - depth = method(fdatagrid) + depth = depth_method(fdatagrid) indices_descending_depth = (-depth).argsort(axis=0) # The mean is the deepest curve self._median = fdatagrid.data_matrix[indices_descending_depth[0]] # Central region and envelope must be computed for outlier detection - central_region = _envelopes._compute_region( - fdatagrid, indices_descending_depth, 0.5) - self._central_envelope = _envelopes._compute_envelope(central_region) + central_region = _envelopes.compute_region( + fdatagrid, + indices_descending_depth, + 0.5, + ) + self._central_envelope = _envelopes.compute_envelope(central_region) # Non-outlying envelope - non_outlying_threshold = _envelopes._non_outlying_threshold( - self._central_envelope, factor) - predicted_outliers = _envelopes._predict_outliers( - fdatagrid, non_outlying_threshold) + non_outlying_threshold = _envelopes.non_outlying_threshold( + self._central_envelope, + factor, + ) + predicted_outliers = _envelopes.predict_outliers( + fdatagrid, + non_outlying_threshold, + ) inliers = fdatagrid[predicted_outliers == 0] - self._non_outlying_envelope = _envelopes._compute_envelope(inliers) + self._non_outlying_envelope = _envelopes.compute_envelope(inliers) self._fdatagrid = fdatagrid self.colormap = plt.cm.get_cmap('Greys') @@ -609,68 +707,51 @@ def __init__(self, fdatagrid, method=ModifiedBandDepth(), factor=1.5): self._outcol = 0.7 @property - def fdatagrid(self): + def fdatagrid(self) -> FDataGrid: return self._fdatagrid @property - def median(self): + def median(self) -> NDArrayFloat: return self._median @property - def central_envelope(self): + def central_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: return self._central_envelope @property - def non_outlying_envelope(self): + def non_outlying_envelope(self) -> Tuple[NDArrayFloat, NDArrayFloat]: return self._non_outlying_envelope @property - def boxcol(self): + def boxcol(self) -> float: return self._boxcol @boxcol.setter - def boxcol(self, value): + def boxcol(self, value: float) -> None: if value < 0 or value > 1: - raise ValueError( - "boxcol must be a number between 0 and 1.") + raise ValueError("boxcol must be a number between 0 and 1.") self._boxcol = value @property - def outcol(self): + def outcol(self) -> float: return self._outcol @outcol.setter - def outcol(self, value): + def outcol(self, value: float) -> None: if value < 0 or value > 1: - raise ValueError( - "outcol must be a number between 0 and 1.") + raise ValueError("outcol must be a number between 0 and 1.") self._outcol = value - def plot(self, chart=None, *, fig=None, axes=None, - n_rows=None, n_cols=None): - """Visualization of the surface boxplot of the fdatagrid (dim_domain=2). - - Args: - fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also - None, the figure is initialized. - axes (list of axis objects, optional): axis over where the graphs - are plotted. If None, see param fig. - n_rows(int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. - n_cols(int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Only - specified if fig and ax are None. - - Returns: - fig (figure): figure object in which the graphs are plotted. + @property + def dim(self) -> int: + return 3 - """ - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout_for_fdata( - self.fdatagrid, fig, axes, n_rows, n_cols) + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: x = self.fdatagrid.grid_points[0] lx = len(x) @@ -678,90 +759,146 @@ def plot(self, chart=None, *, fig=None, axes=None, ly = len(y) X, Y = np.meshgrid(x, y) - for m in range(self.fdatagrid.dim_codomain): + for m, ax in enumerate(axes): # mean sample - axes[m].plot_wireframe(X, Y, np.squeeze(self.median[..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) - axes[m].plot_surface(X, Y, np.squeeze(self.median[..., m]).T, - color=self.colormap(self.boxcol), alpha=0.8) + ax.plot_wireframe( + X, + Y, + np.squeeze(self.median[..., m]).T, + rstride=ly, + cstride=lx, + color=self.colormap(self.boxcol), + ) + ax.plot_surface( + X, + Y, + np.squeeze(self.median[..., m]).T, + color=self.colormap(self.boxcol), + alpha=0.8, + ) # central envelope - axes[m].plot_surface( - X, Y, np.squeeze(self.central_envelope[0][..., m]).T, - color=self.colormap(self.boxcol), alpha=0.5) - axes[m].plot_wireframe( - X, Y, np.squeeze(self.central_envelope[0][..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) - axes[m].plot_surface( - X, Y, np.squeeze(self.central_envelope[1][..., m]).T, - color=self.colormap(self.boxcol), alpha=0.5) - axes[m].plot_wireframe( - X, Y, np.squeeze(self.central_envelope[1][..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) + ax.plot_surface( + X, + Y, + np.squeeze(self.central_envelope[0][..., m]).T, + color=self.colormap(self.boxcol), + alpha=0.5, + ) + ax.plot_wireframe( + X, + Y, + np.squeeze(self.central_envelope[0][..., m]).T, + rstride=ly, + cstride=lx, + color=self.colormap(self.boxcol), + ) + ax.plot_surface( + X, + Y, + np.squeeze(self.central_envelope[1][..., m]).T, + color=self.colormap(self.boxcol), + alpha=0.5, + ) + ax.plot_wireframe( + X, + Y, + np.squeeze(self.central_envelope[1][..., m]).T, + rstride=ly, + cstride=lx, + color=self.colormap(self.boxcol), + ) # box vertical lines - for indices in [(0, 0), (0, ly - 1), (lx - 1, 0), - (lx - 1, ly - 1)]: + for indices in ( + (0, 0), + (0, ly - 1), + (lx - 1, 0), + (lx - 1, ly - 1), + ): x_corner = x[indices[0]] y_corner = y[indices[1]] - axes[m].plot( - [x_corner, x_corner], [y_corner, y_corner], + ax.plot( + [x_corner, x_corner], + [y_corner, y_corner], [ - self.central_envelope[1][..., m][indices[0], - indices[1]], - self.central_envelope[0][..., m][indices[0], - indices[1]]], - color=self.colormap(self.boxcol)) + self.central_envelope[1][..., m][ + indices[0], + indices[1], + ], + self.central_envelope[0][..., m][ + indices[0], + indices[1], + ], + ], + color=self.colormap(self.boxcol), + ) # outlying envelope - axes[m].plot_surface( - X, Y, + ax.plot_surface( + X, + Y, np.squeeze(self.non_outlying_envelope[0][..., m]).T, - color=self.colormap(self.outcol), alpha=0.3) - axes[m].plot_wireframe( - X, Y, + color=self.colormap(self.outcol), + alpha=0.3, + ) + ax.plot_wireframe( + X, + Y, np.squeeze(self.non_outlying_envelope[0][..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.outcol)) - axes[m].plot_surface( - X, Y, + rstride=ly, + cstride=lx, + color=self.colormap(self.outcol), + ) + ax.plot_surface( + X, + Y, np.squeeze(self.non_outlying_envelope[1][..., m]).T, - color=self.colormap(self.outcol), alpha=0.3) - axes[m].plot_wireframe( - X, Y, + color=self.colormap(self.outcol), + alpha=0.3, + ) + ax.plot_wireframe( + X, + Y, np.squeeze(self.non_outlying_envelope[1][..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.outcol)) + rstride=ly, + cstride=lx, + color=self.colormap(self.outcol), + ) # vertical lines from central to outlying envelope x_index = math.floor(lx / 2) x_central = x[x_index] y_index = math.floor(ly / 2) y_central = y[y_index] - axes[m].plot( - [x_central, x_central], [y_central, y_central], - [self.non_outlying_envelope[1][..., m][x_index, y_index], - self.central_envelope[1][..., m][x_index, y_index]], - color=self.colormap(self.boxcol)) - axes[m].plot( - [x_central, x_central], [y_central, y_central], - [self.non_outlying_envelope[0][..., m][x_index, y_index], - self.central_envelope[0][..., m][x_index, y_index]], - color=self.colormap(self.boxcol)) + ax.plot( + [x_central, x_central], + [y_central, y_central], + [ + self.non_outlying_envelope[1][..., m][x_index, y_index], + self.central_envelope[1][..., m][x_index, y_index], + ], + color=self.colormap(self.boxcol), + ) + ax.plot( + [x_central, x_central], + [y_central, y_central], + [ + self.non_outlying_envelope[0][..., m][x_index, y_index], + self.central_envelope[0][..., m][x_index, y_index], + ], + color=self.colormap(self.boxcol), + ) _set_labels(self.fdatagrid, fig, axes) - return fig - - def __repr__(self): + def __repr__(self) -> str: """Return repr(self).""" - return ((f"SurfaceBoxplot(" - f"\nFDataGrid={repr(self.fdatagrid)}," - f"\nmedian={repr(self.median)}," - f"\ncentral envelope={repr(self.central_envelope)}," - f"\noutlying envelope={repr(self.non_outlying_envelope)})") - .replace('\n', '\n ')) + return ( + f"SurfaceBoxplot(" + f"\nFDataGrid={repr(self.fdatagrid)}," + f"\nmedian={repr(self.median)}," + f"\ncentral envelope={repr(self.central_envelope)}," + f"\noutlying envelope={repr(self.non_outlying_envelope)})" + ).replace('\n', '\n ') diff --git a/skfda/exploratory/visualization/_ddplot.py b/skfda/exploratory/visualization/_ddplot.py new file mode 100644 index 000000000..bfa6707cd --- /dev/null +++ b/skfda/exploratory/visualization/_ddplot.py @@ -0,0 +1,140 @@ +"""DD-Plot Module. + +This module contains the necessary functions to construct the DD-Plot. +To do this depth is calculated for the two chosen distributions, and then +a scatter plot is created of this two variables. +""" + +from typing import Optional, TypeVar, Union + +import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from ...exploratory.depth.multivariate import Depth +from ...representation._functional_data import FData +from ._baseplot import BasePlot + +T = TypeVar('T', bound=FData) + + +class DDPlot(BasePlot): + """ + DDPlot visualization. + + Plot the depth of our fdata elements in two + different distributions, one in each axis. It is useful to understand + how our data is more related with one subset of data / distribution + than another one. + Args: + fdata: functional data set that we want to examine. + dist1: functional data set that represents the first distribution that + we want to use to compute the depth (Depth X). + dist2: functional data set that represents the second distribution that + we want to use to compute the depth (Depth Y). + depth_method: method that will be used to compute the depths of the + data with respect to the distributions. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + axes: axis where the graphs are plotted. If None, see param fig. + Attributes: + depth_dist1: result of the calculation of the depth_method into our + first distribution (dist1). + depth_dist2: result of the calculation of the depth_method into our + second distribution (dist2). + """ + + def __init__( + self, + fdata: T, + dist1: T, + dist2: T, + chart: Union[Figure, Axes, None] = None, + *, + depth_method: Depth[T], + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + ) -> None: + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + ) + self.fdata = fdata + self.depth_method = depth_method + self.depth_method.fit(fdata) + self.depth_dist1 = self.depth_method( + self.fdata, + distribution=dist1, + ) + self.depth_dist2 = self.depth_method( + self.fdata, + distribution=dist2, + ) + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot( + self, + fig: Figure, + axes: Axes, + ) -> None: + """ + Plot DDPlot graph. + + Plot the depth of our fdata elements in the two different + distributions,one in each axis. It is useful to understand how + our data is more related with one subset of data / distribution + than another one. + Returns: + fig (figure object): figure object in which the depths will be + scattered. + """ + self.artists = np.zeros( + (self.n_samples, 1), + dtype=Artist, + ) + margin = 0.025 + width_aux_line = 0.35 + color_aux_line = "gray" + + ax = axes[0] + + for i, (d1, d2) in enumerate(zip(self.depth_dist1, self.depth_dist2)): + self.artists[i, 0] = ax.scatter( + d1, + d2, + picker=True, + pickradius=2, + ) + + # Set labels of graph + if self.fdata.dataset_name is not None: + ax.set_title(self.fdata.dataset_name) + ax.set_xlabel("X depth") + ax.set_ylabel("Y depth") + ax.set_xlim( + [ + self.depth_method.min - margin, + self.depth_method.max + margin, + ], + ) + ax.set_ylim( + [ + self.depth_method.min - margin, + self.depth_method.max + margin, + ], + ) + ax.plot( + [0, 1], + linewidth=width_aux_line, + color=color_aux_line, + ) diff --git a/skfda/exploratory/visualization/_magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py index 9e3e5e4d7..54ce674e8 100644 --- a/skfda/exploratory/visualization/_magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/_magnitude_shape_plot.py @@ -5,22 +5,28 @@ detection method is implemented. """ +from __future__ import annotations -import matplotlib +from typing import Optional, Sequence, Union +import matplotlib import matplotlib.pyplot as plt import numpy as np - +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.colors import Colormap +from matplotlib.figure import Figure + +from ... import FDataGrid +from ...representation._typing import NDArrayFloat, NDArrayInt +from ..depth import Depth from ..outliers import DirectionalOutlierDetector -from ._utils import _figure_to_svg, _get_figure_and_axes, _set_figure_layout +from ._baseplot import BasePlot -__author__ = "Amanda Hernando Bernabé" -__email__ = "amanda.hernando@estudiante.uam.es" - - -class MagnitudeShapePlot: - r"""Implementation of the magnitude-shape plot +class MagnitudeShapePlot(BasePlot): + r""" + Implementation of the magnitude-shape plot. This plot, which is based on the calculation of the :func:`directional outlyingness ` @@ -34,8 +40,9 @@ class MagnitudeShapePlot: The outliers are detected using an instance of :class:`DirectionalOutlierDetector`. - Args: + For more information see :footcite:ts:`dai+genton_2018_visualization`. + Args: fdatagrid (FDataGrid): Object containing the data. multivariate_depth (:ref:`depth measure `, optional): Method used to order the data. Defaults to :class:`projection @@ -66,7 +73,6 @@ class MagnitudeShapePlot: RandomState instance used by np.random. By default, it is 0. Attributes: - points(numpy.ndarray): 2-dimensional matrix where each row contains the points plotted in the graph. outliers (1-D array, (fdatagrid.n_samples,)): Contains 1 or 0 to denote @@ -98,7 +104,6 @@ class MagnitudeShapePlot: MagnitudeShapePlot(fd) Example: - >>> import skfda >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], @@ -133,10 +138,10 @@ class MagnitudeShapePlot: [-1. ], [-1. ], [-1. ]]]), - grid_points=(array([ 0., 2., 4., 6., 8., 10.]),), + grid_points=(array([ 0., 2., 4., 6., 8., 10.]),), domain_range=((0.0, 10.0),), ...), - multivariate_depth=ProjectionDepth(), + multivariate_depth=None, pointwise_weights=None, alpha=0.993, points=array([[ 1.66666667, 0.12777778], @@ -146,55 +151,71 @@ class MagnitudeShapePlot: outliers=array([False, False, False, False]), colormap=seismic, color=0.2, - outliercol=(0.8,), + outliercol=0.8, xlabel='MO', ylabel='VO', title='MS-Plot') References: - - Dai, W., & Genton, M. G. (2018). Multivariate Functional Data - Visualization and Outlier Detection. Journal of Computational - and Graphical Statistics, 27(4), 923-934. - https://doi.org/10.1080/10618600.2018.1473781 + .. footbibliography:: """ - def __init__(self, fdatagrid, **kwargs): + def __init__( + self, + fdatagrid: FDataGrid, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Sequence[Axes]] = None, + **kwargs, + ) -> None: """Initialization of the MagnitudeShapePlot class. Args: - fdatagrid (FDataGrid): Object containing the data. - multivariate_depth (:ref:`depth measure `, optional): - Method used to order the data. Defaults to :class:`projection - depth `. - pointwise_weights (array_like, optional): an array containing the + fdatagrid: Object containing the data. + multivariate_depth (:ref:`depth measure `, + optional): Method used to order the data. Defaults to + :class:`projection depth + `. + pointwise_weights: an array containing the weights of each points of discretisati on where values have been recorded. - alpha (float, optional): Denotes the quantile to choose the cutoff + alpha: Denotes the quantile to choose the cutoff value for detecting outliers Defaults to 0.993, which is used in the classical boxplot. - assume_centered (boolean, optional): If True, the support of the + assume_centered: If True, the support of the robust location and the covariance estimates is computed, and a covariance estimate is recomputed from it, without centering the data. Useful to work with data whose mean is significantly equal to zero but is not exactly zero. If False, default value, the robust location and covariance are directly computed with the FastMCD algorithm without additional treatment. - support_fraction (float, 0 < support_fraction < 1, optional): The - proportion of points to be included in the support of the - raw MCD estimate. + support_fraction: The proportion of points to be included in the + support of the raw MCD estimate. Default is None, which implies that the minimum value of support_fraction will be used within the algorithm: [n_sample + n_features + 1] / 2 - random_state (int, RandomState instance or None, optional): If int, - random_state is the seed used by the random number generator; - If RandomState instance, random_state is the random number - generator; If None, the random number generator is the - RandomState instance used by np.random. By default, it is 0. + random_state: If int, random_state is the seed used by the random + number generator; If RandomState instance, random_state is + the random number generator; If None, the random number + generator is the RandomState instance used by np.random. + By default, it is 0. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + axes: axis where the graphs are plotted. If None, see param fig. """ - + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + ) if fdatagrid.dim_codomain > 1: raise NotImplementedError( "Only support 1 dimension on the codomain.") @@ -209,52 +230,54 @@ def __init__(self, fdatagrid, **kwargs): self._outliers = outliers self._colormap = plt.cm.get_cmap('seismic') self._color = 0.2 - self._outliercol = 0.8, + self._outliercol = 0.8 self.xlabel = 'MO' self.ylabel = 'VO' self.title = 'MS-Plot' @property - def fdatagrid(self): + def fdatagrid(self) -> FDataGrid: return self._fdatagrid @property - def multivariate_depth(self): + def multivariate_depth(self) -> Optional[Depth[NDArrayFloat]]: return self.outlier_detector.multivariate_depth @property - def pointwise_weights(self): + def pointwise_weights(self) -> Optional[NDArrayFloat]: return self.outlier_detector.pointwise_weights @property - def alpha(self): + def alpha(self) -> float: return self.outlier_detector.alpha @property - def points(self): + def points(self) -> NDArrayFloat: return self.outlier_detector.points_ @property - def outliers(self): + def outliers(self) -> NDArrayInt: return self._outliers @property - def colormap(self): + def colormap(self) -> Colormap: return self._colormap @colormap.setter - def colormap(self, value): - if not isinstance(value, matplotlib.colors.LinearSegmentedColormap): - raise ValueError("colormap must be of type " - "matplotlib.colors.LinearSegmentedColormap") + def colormap(self, value: Colormap) -> None: + if not isinstance(value, matplotlib.colors.Colormap): + raise ValueError( + "colormap must be of type " + "matplotlib.colors.Colormap", + ) self._colormap = value @property - def color(self): + def color(self) -> float: return self._color @color.setter - def color(self, value): + def color(self, value: float) -> None: if value < 0 or value > 1: raise ValueError( "color must be a number between 0 and 1.") @@ -262,62 +285,63 @@ def color(self, value): self._color = value @property - def outliercol(self): + def outliercol(self) -> float: return self._outliercol @outliercol.setter - def outliercol(self, value): + def outliercol(self, value: float) -> None: if value < 0 or value > 1: raise ValueError( "outcol must be a number between 0 and 1.") self._outliercol = value - def plot(self, chart=None, *, fig=None, axes=None,): - """Visualization of the magnitude shape plot of the fdatagrid. - - Args: - ax (axes object, optional): axes over where the graph is plotted. - Defaults to matplotlib current axis. - - Returns: - fig (figure object): figure object in which the graph is plotted. - - """ - - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout(fig, axes) - + @property + def n_samples(self) -> int: + return self.fdatagrid.n_samples + + def _plot( + self, + fig: Figure, + axes: Axes, + ) -> None: + + self.artists = np.zeros( + (self.n_samples, 1), + dtype=Artist, + ) colors = np.zeros((self.fdatagrid.n_samples, 4)) colors[np.where(self.outliers == 1)] = self.colormap(self.outliercol) colors[np.where(self.outliers == 0)] = self.colormap(self.color) colors_rgba = [tuple(i) for i in colors] - axes[0].scatter(self.points[:, 0].ravel(), self.points[:, 1].ravel(), - color=colors_rgba) + + for i, _ in enumerate(self.points[:, 0].ravel()): + self.artists[i, 0] = axes[0].scatter( + self.points[:, 0].ravel()[i], + self.points[:, 1].ravel()[i], + color=colors_rgba[i], + picker=True, + pickradius=2, + ) axes[0].set_xlabel(self.xlabel) axes[0].set_ylabel(self.ylabel) axes[0].set_title(self.title) - return fig - - def __repr__(self): + def __repr__(self) -> str: """Return repr(self).""" - return (f"MagnitudeShapePlot(" - f"\nFDataGrid={repr(self.fdatagrid)}," - f"\nmultivariate_depth={self.multivariate_depth}," - f"\npointwise_weights={repr(self.pointwise_weights)}," - f"\nalpha={repr(self.alpha)}," - f"\npoints={repr(self.points)}," - f"\noutliers={repr(self.outliers)}," - f"\ncolormap={self.colormap.name}," - f"\ncolor={repr(self.color)}," - f"\noutliercol={repr(self.outliercol)}," - f"\nxlabel={repr(self.xlabel)}," - f"\nylabel={repr(self.ylabel)}," - f"\ntitle={repr(self.title)})").replace('\n', '\n ') - - def _repr_svg_(self): - fig = self.plot() - plt.close(fig) - return _figure_to_svg(fig) + return ( + f"MagnitudeShapePlot(" + f"\nFDataGrid={repr(self.fdatagrid)}," + f"\nmultivariate_depth={self.multivariate_depth}," + f"\npointwise_weights={repr(self.pointwise_weights)}," + f"\nalpha={repr(self.alpha)}," + f"\npoints={repr(self.points)}," + f"\noutliers={repr(self.outliers)}," + f"\ncolormap={self.colormap.name}," + f"\ncolor={repr(self.color)}," + f"\noutliercol={repr(self.outliercol)}," + f"\nxlabel={repr(self.xlabel)}," + f"\nylabel={repr(self.ylabel)}," + f"\ntitle={repr(self.title)})" + ).replace('\n', '\n ') diff --git a/skfda/exploratory/visualization/_multiple_display.py b/skfda/exploratory/visualization/_multiple_display.py new file mode 100644 index 000000000..79316a1d8 --- /dev/null +++ b/skfda/exploratory/visualization/_multiple_display.py @@ -0,0 +1,505 @@ +import copy +import itertools +from functools import partial +from typing import ( + Generator, + List, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) + +import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.backend_bases import Event, LocationEvent, MouseEvent +from matplotlib.collections import PathCollection +from matplotlib.figure import Figure +from matplotlib.text import Annotation +from matplotlib.widgets import Slider, Widget + +from ._baseplot import BasePlot +from ._utils import _get_axes_shape, _get_figure_and_axes, _set_figure_layout + + +def _set_val_noevents(widget: Widget, val: float) -> None: + e = widget.eventson + widget.eventson = False + widget.set_val(val) + widget.eventson = e + + +class MultipleDisplay: + """ + MultipleDisplay class used to combine and interact with plots. + + This module is used to combine different BasePlot objects that + represent the same curves or surfaces, and represent them + together in the same figure. Besides this, it includes + the functionality necessary to interact with the graphics + by clicking the points, hovering over them... Picking the points allow + us to see our selected function standing out among the others in all + the axes. It is also possible to add widgets to interact with the + plots. + Args: + displays: Baseplot objects that will be plotted in the fig. + criteria: Sequence of criteria used to order the points in the + slider widget. The size should be equal to sliders, as each + criterion is for one slider. + sliders: Sequence of widgets that will be plotted. + label_sliders: Label of each of the sliders. + chart: Figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: Figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + axes: Axis where the graphs are plotted. If None, see param fig. + Attributes: + length_data: Number of instances or curves of the different displays. + clicked: Boolean indicating whether a point has being clicked. + selected_sample: Index of the function selected with the interactive + module or widgets. + """ + + def __init__( + self, + displays: Union[BasePlot, Sequence[BasePlot]], + criteria: Union[Sequence[float], Sequence[Sequence[float]]] = (), + sliders: Union[Type[Widget], Sequence[Type[Widget]]] = (), + label_sliders: Union[str, Sequence[str], None] = None, + chart: Union[Figure, Axes, None] = None, + fig: Optional[Figure] = None, + axes: Optional[Sequence[Axes]] = None, + ): + if isinstance(displays, BasePlot): + displays = (displays,) + + self.displays = [copy.copy(d) for d in displays] + self._n_graphs = sum(d.n_subplots for d in self.displays) + self.length_data = next( + d.n_samples + for d in self.displays + if d.n_samples is not None + ) + self.sliders: List[Widget] = [] + self.criteria: List[List[int]] = [] + self.selected_sample: Optional[int] = None + self._tag = self._create_annotation() + + if len(criteria) != 0 and not isinstance(criteria[0], Sequence): + criteria = (criteria,) + + criteria = cast(Sequence[Sequence[float]], criteria) + + if not isinstance(sliders, Sequence): + sliders = (sliders,) + + if isinstance(label_sliders, str): + label_sliders = (label_sliders,) + + if len(criteria) != len(sliders): + raise ValueError( + f"Size of criteria, and sliders should be equal " + f"(have {len(criteria)} and {len(sliders)}).", + ) + + self._init_axes( + chart, + fig=fig, + axes=axes, + extra=len(criteria), + ) + + self._create_sliders( + criteria=criteria, + sliders=sliders, + label_sliders=label_sliders, + ) + + def _init_axes( + self, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Sequence[Axes]] = None, + extra: int = 0, + ) -> None: + """ + Initialize the axes and figure. + + Args: + chart: Figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: Figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + axes: Axis where the graphs are plotted. If None, see param fig. + extra: integer indicating the extra axes needed due to the + necessity for them to plot the sliders. + + """ + widget_aspect = 1 / 8 + fig, axes = _get_figure_and_axes(chart, fig, axes) + if len(axes) not in {0, self._n_graphs + extra}: + raise ValueError("Invalid number of axes.") + + n_rows, n_cols = _get_axes_shape(self._n_graphs + extra) + + dim = list( + itertools.chain.from_iterable( + [d.dim] * d.n_subplots + for d in self.displays + ), + ) + [2] * extra + + number_axes = n_rows * n_cols + fig, axes = _set_figure_layout( + fig=fig, + axes=axes, + n_axes=self._n_graphs + extra, + dim=dim, + ) + + for i in range(self._n_graphs, number_axes): + if i >= self._n_graphs + extra: + axes[i].set_visible(False) + else: + axes[i].set_box_aspect(widget_aspect) + + self.fig = fig + self.axes = axes + + def _create_sliders( + self, + *, + criteria: Sequence[Sequence[float]], + sliders: Sequence[Type[Widget]], + label_sliders: Optional[Sequence[str]] = None, + ) -> None: + """ + Create the sliders with the criteria selected. + + Args: + criteria: Different criterion for each of the sliders. + sliders: Widget types. + label_sliders: Sequence of the names of each slider. + + """ + for c in criteria: + if len(c) != self.length_data: + raise ValueError( + "Slider criteria should be of the same size as data", + ) + + for k, criterion in enumerate(criteria): + label = label_sliders[k] if label_sliders else None + + self.add_slider( + axes=self.axes[self._n_graphs + k], + criterion=criterion, + widget_class=sliders[k], + label=label, + ) + + def _create_annotation(self) -> Annotation: + tag = Annotation( + "", + xy=(0, 0), + xytext=(20, 20), + textcoords="offset points", + bbox={ + "boxstyle": "round", + "fc": "w", + }, + arrowprops={ + "arrowstyle": "->", + }, + ) + + tag.get_bbox_patch().set_facecolor(color='khaki') + intensity = 0.8 + tag.get_bbox_patch().set_alpha(intensity) + + return tag + + def _update_annotation( + self, + tag: Annotation, + *, + axes: Axes, + sample_number: int, + position: Tuple[float, float], + ) -> None: + """ + Auxiliary method used to update the hovering annotations. + + Method used to update the annotations that appear while + hovering a scattered point. The annotations indicate + the index and coordinates of the point hovered. + Args: + tag: Annotation to update. + axes: Axes were the annotation belongs. + sample_number: Number of the current sample. + """ + xdata_graph, ydata_graph = position + + tag.xy = (xdata_graph, ydata_graph) + text = f"{sample_number}: ({xdata_graph:.2f}, {ydata_graph:.2f})" + tag.set_text(text) + + x_axis = axes.get_xlim() + y_axis = axes.get_ylim() + + label_xpos = 20 + label_ypos = 20 + if (xdata_graph - x_axis[0]) > (x_axis[1] - xdata_graph): + label_xpos = -80 + + if (ydata_graph - y_axis[0]) > (y_axis[1] - ydata_graph): + label_ypos = -20 + + if tag.figure: + tag.remove() + tag.figure = None + axes.add_artist(tag) + tag.set_transform(axes.transData) + tag.set_position((label_xpos, label_ypos)) + + def plot(self) -> Figure: + """ + Plot Multiple Display method. + + Plot the different BasePlot objects and widgets selected. + Activates the interactivity functionality of clicking and + hovering points. When clicking a point, the rest will be + made partially transparent in all the corresponding graphs. + Returns: + fig: figure object in which the displays and + widgets will be plotted. + """ + if self._n_graphs > 1: + for d in self.displays[1:]: + if ( + d.n_samples is not None + and d.n_samples != self.length_data + ): + raise ValueError( + "Length of some data sets are not equal ", + ) + + for ax in self.axes[:self._n_graphs]: + ax.clear() + + int_index = 0 + for disp in self.displays: + axes_needed = disp.n_subplots + end_index = axes_needed + int_index + disp._set_figure_and_axes(axes=self.axes[int_index:end_index]) + disp.plot() + int_index = end_index + + self.fig.canvas.mpl_connect('motion_notify_event', self.hover) + self.fig.canvas.mpl_connect('pick_event', self.pick) + + self._tag.set_visible(False) + + self.fig.suptitle("Multiple display") + self.fig.tight_layout() + + return self.fig + + def _sample_artist_from_event( + self, + event: LocationEvent, + ) -> Optional[Tuple[int, Artist]]: + """Get the number of sample and artist under a location event.""" + for d in self.displays: + if d.artists is None: + continue + + try: + i = d.axes_.index(event.inaxes) + except ValueError: + continue + + for j, artist in enumerate(d.artists[:, i]): + if not isinstance(artist, PathCollection): + return None + + if artist.contains(event)[0]: + return j, artist + + return None + + def hover(self, event: MouseEvent) -> None: + """ + Activate the annotation when hovering a point. + + Callback method that activates the annotation when hovering + a specific point in a graph. The annotation is a description + of the point containing its coordinates. + Args: + event: event object containing the artist of the point + hovered. + + """ + found_artist = self._sample_artist_from_event(event) + + if event.inaxes is not None and found_artist is not None: + sample_number, artist = found_artist + + self._update_annotation( + self._tag, + axes=event.inaxes, + sample_number=sample_number, + position=artist.get_offsets()[0], + ) + self._tag.set_visible(True) + self.fig.canvas.draw_idle() + elif self._tag.get_visible(): + self._tag.set_visible(False) + self.fig.canvas.draw_idle() + + def pick(self, event: Event) -> None: + """ + Activate interactive functionality when picking a point. + + Callback method that is activated when a point is picked. + If no point was clicked previously, all the points but the + one selected will be more transparent in all the graphs. + If a point was clicked already, this new point will be the + one highlighted among the rest. If the same point is clicked, + the initial state of the graphics is restored. + Args: + event: event object containing the artist of the point + picked. + """ + selected_sample = self._sample_from_artist(event.artist) + + if selected_sample is not None: + if self.selected_sample == selected_sample: + self._deselect_samples() + else: + self._select_sample(selected_sample) + + def _sample_from_artist(self, artist: Artist) -> Optional[int]: + """Return the sample corresponding to an artist.""" + for d in self.displays: + + if d.artists is None: + continue + + for i, a in enumerate(d.axes_): + if a == artist.axes: + if len(d.axes_) == 1: + return np.where(d.artists == artist)[0][0] + else: + return np.where(d.artists[:, i] == artist)[0][0] + + return None + + def _visit_artists(self) -> Generator[Tuple[int, Artist], None, None]: + for i in range(self.length_data): + for d in self.displays: + if d.artists is None: + continue + + yield from ((i, artist) for artist in np.ravel(d.artists[i])) + + def _select_sample(self, selected_sample: int) -> None: + """Reduce the transparency of all the points but the selected one.""" + for i, artist in self._visit_artists(): + artist.set_alpha(1.0 if i == selected_sample else 0.1) + + for criterion, slider in zip(self.criteria, self.sliders): + val_widget = criterion.index(selected_sample) + _set_val_noevents(slider, val_widget) + + self.selected_sample = selected_sample + self.fig.canvas.draw_idle() + + def _deselect_samples(self) -> None: + """Restore the original transparency of all the points.""" + for _, artist in self._visit_artists(): + artist.set_alpha(1) + + self.selected_sample = None + self.fig.canvas.draw_idle() + + def add_slider( + self, + axes: Axes, + criterion: Sequence[float], + widget_class: Type[Widget] = Slider, + label: Optional[str] = None, + ) -> None: + """ + Add the slider to the MultipleDisplay object. + + Args: + axes: Axes for the widget. + criterion: Criterion used for the slider. + widget_class: Widget type. + label: Name of the slider. + """ + full_desc = "" if label is None else label + + widget = widget_class( + ax=axes, + label=full_desc, + valmin=0, + valmax=self.length_data - 1, + valinit=0, + valstep=1, + ) + + self.sliders.append(widget) + + axes.annotate( + '0', + xy=(0, -0.5), + xycoords='axes fraction', + annotation_clip=False, + ) + + axes.annotate( + str(self.length_data - 1), + xy=(0.95, -0.5), + xycoords='axes fraction', + annotation_clip=False, + ) + + criterion_sample_indexes = [ + x for _, x in sorted(zip(criterion, range(self.length_data))) + ] + + self.criteria.append(criterion_sample_indexes) + + on_changed_function = partial( + self._value_updated, + criterion_sample_indexes=criterion_sample_indexes, + ) + + widget.on_changed(on_changed_function) + + def _value_updated( + self, + value: int, + criterion_sample_indexes: Sequence[int], + ) -> None: + """ + Update the graphs when a widget is clicked. + + Args: + value: Current value of the widget. + criterion_sample_indexes: Sample numbers ordered using the + criterion. + + """ + self.selected_sample = criterion_sample_indexes[value] + self._select_sample(self.selected_sample) diff --git a/skfda/exploratory/visualization/_outliergram.py b/skfda/exploratory/visualization/_outliergram.py new file mode 100644 index 000000000..d7410f41f --- /dev/null +++ b/skfda/exploratory/visualization/_outliergram.py @@ -0,0 +1,133 @@ +"""Outliergram Module. + +This module contains the methods used to plot shapes in order to detect +shape outliers in our dataset. In order to do this, we plot the +Modified Band Depth and Modified Epigraph Index, that will help us detect +these outliers. The motivation of the method is that it is easy to find +magnitude outliers, but there is a necessity of capturing this other type. +""" + +from typing import Optional, Sequence, Union + +import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from ... import FDataGrid +from ..outliers import OutliergramOutlierDetector +from ._baseplot import BasePlot + + +class Outliergram(BasePlot): + """ + Outliergram method of visualization. + + Plots the Modified Band Depth (MBD) on the Y axis and the Modified + Epigraph Index (MEI) on the X axis. This points will create the form of + a parabola. The shape outliers will be the points that appear far from + this curve. + Args: + fdata: functional data set that we want to examine. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + axes: axis where the graphs are plotted. If None, see param fig. + n_rows: designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols: designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. + Attributes: + mbd: result of the calculation of the Modified Band Depth on our + dataset. Represents the mean time a curve stays between other pair + of curves, being a good measure of centrality. + mei: result of the calculation of the Modified Epigraph Index on our + dataset. Represents the mean time a curve stays below other curve. + References: + López-Pintado S., Romo J.. (2011). A half-region depth for functional + data, Computational Statistics & Data Analysis, volume 55 + (page 1679-1695). + Arribas-Gil A., Romo J.. Shape outlier detection and visualization for + functional data: the outliergram + https://academic.oup.com/biostatistics/article/15/4/603/266279 + """ + + def __init__( + self, + fdata: FDataGrid, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + factor: float = 1.5, + ) -> None: + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + ) + self.fdata = fdata + self.factor = factor + self.outlier_detector = OutliergramOutlierDetector(factor=factor) + self.outlier_detector.fit(fdata) + indices = np.argsort(self.outlier_detector.mei_) + self._parabola_ordered = self.outlier_detector.parabola_[indices] + self._mei_ordered = self.outlier_detector.mei_[indices] + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot( + self, + fig: Figure, + axes: Axes, + ) -> None: + + self.artists = np.zeros( + (self.n_samples, 1), + dtype=Artist, + ) + + for i, (mei, mbd) in enumerate( + zip(self.outlier_detector.mei_, self.outlier_detector.mbd_), + ): + self.artists[i, 0] = axes[0].scatter( + mei, + mbd, + picker=2, + ) + + axes[0].plot( + self._mei_ordered, + self._parabola_ordered, + ) + + shifted_parabola = ( + self._parabola_ordered + - self.outlier_detector.max_inlier_distance_ + ) + + axes[0].plot( + self._mei_ordered, + shifted_parabola, + linestyle='dashed', + ) + + # Set labels of graph + if self.fdata.dataset_name is not None: + axes[0].set_title(self.fdata.dataset_name) + + axes[0].set_xlabel("MEI") + axes[0].set_ylabel("MBD") + axes[0].set_xlim([0, 1]) + axes[0].set_ylim([ + 0, # Minimum MBD + 1, # Maximum MBD + ]) diff --git a/skfda/exploratory/visualization/_parametric_plot.py b/skfda/exploratory/visualization/_parametric_plot.py new file mode 100644 index 000000000..6ba990bf8 --- /dev/null +++ b/skfda/exploratory/visualization/_parametric_plot.py @@ -0,0 +1,136 @@ +"""Parametric Plot Module. + +This module contains the functionality in charge of plotting +two different functions as coordinates, this can be done giving +one FData, with domain 1 and codomain 2, or giving two FData, both +of them with domain 1 and codomain 1. +""" + +from typing import Mapping, Optional, Sequence, TypeVar, Union + +import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from ...representation import FData +from ._baseplot import BasePlot +from ._utils import ColorLike +from .representation import Indexable, _get_color_info + +K = TypeVar('K', contravariant=True) + + +class ParametricPlot(BasePlot): + """ + Parametric Plot visualization. + + This class contains the functionality in charge of plotting + two different functions as coordinates, this can be done giving + one FData, with domain 1 and codomain 2, or giving two FData, both + of them with domain 1 and codomain 1. + Args: + fdata1: functional data set that we will use for the graph. If it has + a dim_codomain = 1, the fdata2 will be needed. + fdata2: optional functional data set, that will be needed if the fdata1 + has dim_codomain = 1. + chart: figure over with the graphs are plotted or axis over + where the graphs are plotted. If None and ax is also + None, the figure is initialized. + fig: figure over with the graphs are plotted in case ax is not + specified. If None and ax is also None, the figure is + initialized. + ax: axis where the graphs are plotted. If None, see param fig. + """ + + def __init__( + self, + fdata1: FData, + fdata2: Optional[FData] = None, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + group: Optional[Sequence[K]] = None, + group_colors: Optional[Indexable[K, ColorLike]] = None, + group_names: Optional[Indexable[K, str]] = None, + legend: bool = False, + ) -> None: + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + ) + self.fdata1 = fdata1 + self.fdata2 = fdata2 + + if self.fdata2 is not None: + self.fd_final = self.fdata1.concatenate( + self.fdata2, as_coordinates=True, + ) + else: + self.fd_final = self.fdata1 + + self.group = group + self.group_names = group_names + self.group_colors = group_colors + self.legend = legend + + @property + def n_samples(self) -> int: + return self.fd_final.n_samples + + def _plot( + self, + fig: Figure, + axes: Axes, + ) -> None: + + self.artists = np.zeros((self.n_samples, 1), dtype=Artist) + + sample_colors, patches = _get_color_info( + self.fd_final, + self.group, + self.group_names, + self.group_colors, + self.legend, + ) + + color_dict: Mapping[str, Union[ColorLike, None]] = {} + + if ( + self.fd_final.dim_domain == 1 + and self.fd_final.dim_codomain == 2 + ): + ax = axes[0] + + for i in range(self.fd_final.n_samples): + + if sample_colors is not None: + color_dict["color"] = sample_colors[i] + + self.artists[i, 0] = ax.plot( + self.fd_final.data_matrix[i][:, 0].tolist(), + self.fd_final.data_matrix[i][:, 1].tolist(), + **color_dict, + )[0] + + else: + raise ValueError( + "Error in data arguments,", + "codomain or domain is not correct.", + ) + + if self.fd_final.dataset_name is not None: + fig.suptitle(self.fd_final.dataset_name) + + if self.fd_final.coordinate_names[0] is None: + ax.set_xlabel("Function 1") + else: + ax.set_xlabel(self.fd_final.coordinate_names[0]) + + if self.fd_final.coordinate_names[1] is None: + ax.set_ylabel("Function 2") + else: + ax.set_ylabel(self.fd_final.coordinate_names[1]) diff --git a/skfda/exploratory/visualization/_utils.py b/skfda/exploratory/visualization/_utils.py index 021f11832..e9aa766cc 100644 --- a/skfda/exploratory/visualization/_utils.py +++ b/skfda/exploratory/visualization/_utils.py @@ -1,32 +1,55 @@ import io import math import re +from itertools import repeat +from typing import Optional, Sequence, Tuple, TypeVar, Union -import matplotlib.axes import matplotlib.backends.backend_svg -import matplotlib.figure - import matplotlib.pyplot as plt +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from typing_extensions import Protocol + +from ...representation._functional_data import FData non_close_text = '[^>]*?' svg_width_regex = re.compile( - f'()') + f'()', +) svg_width_replacement = r'\g<1>100%\g<2>' svg_height_regex = re.compile( - f'()') + f'()', +) svg_height_replacement = r'\g<1>\g<2>' +ColorLike = Union[ + Tuple[float, float, float], + Tuple[float, float, float, float], + str, + Sequence[float], +] -def _create_figure(): - """Create figure using the default backend.""" - fig = plt.figure() +K = TypeVar('K', contravariant=True) +V = TypeVar('V', covariant=True) - return fig +class Indexable(Protocol[K, V]): + """Class Indexable used to type _get_color_info.""" -def _figure_to_svg(figure): - """Return the SVG representation of a figure.""" + def __getitem__(self, __key: K) -> V: + pass + def __len__(self) -> int: + pass + + +def _create_figure() -> Figure: + """Create figure using the default backend.""" + return plt.figure() + + +def _figure_to_svg(figure: Figure) -> str: + """Return the SVG representation of a figure.""" old_canvas = figure.canvas matplotlib.backends.backend_svg.FigureCanvas(figure) output = io.BytesIO() @@ -36,20 +59,30 @@ def _figure_to_svg(figure): decoded_data = data.decode('utf-8') new_data = svg_width_regex.sub( - svg_width_replacement, decoded_data, count=1) - new_data = svg_height_regex.sub( - svg_height_replacement, new_data, count=1) - - return new_data - - -def _get_figure_and_axes(chart=None, fig=None, axes=None): + svg_width_replacement, + decoded_data, + count=1, + ) + + return svg_height_regex.sub( + svg_height_replacement, + new_data, + count=1, + ) + + +def _get_figure_and_axes( + chart: Union[Figure, Axes, Sequence[Axes], None] = None, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, +) -> Tuple[Figure, Sequence[Axes]]: """Obtain the figure and axes from the arguments.""" - num_defined = sum(e is not None for e in (chart, fig, axes)) if num_defined > 1: - raise ValueError("Only one of chart, fig and axes parameters" - "can be passed as an argument.") + raise ValueError( + "Only one of chart, fig and axes parameters" + "can be passed as an argument.", + ) # Parse chart argument if chart is not None: @@ -59,61 +92,89 @@ def _get_figure_and_axes(chart=None, fig=None, axes=None): axes = chart if fig is None and axes is None: - fig = _create_figure() - axes = [] + new_fig = _create_figure() + new_axes = [] elif fig is not None: - axes = fig.axes + new_fig = fig + new_axes = fig.axes else: - if isinstance(axes, matplotlib.axes.Axes): + assert axes is not None + if isinstance(axes, Axes): axes = [axes] - fig = axes[0].figure - - return fig, axes - - -def _get_axes_shape(n_axes, n_rows=None, n_cols=None): - """Get the number of rows and columns of the subplots""" - - if ((n_rows is not None and n_cols is not None) - and ((n_rows * n_cols) < n_axes)): - raise ValueError(f"The number of rows ({n_rows}) multiplied by " - f"the number of columns ({n_cols}) " - f"is less than the number of required " - f"axes ({n_axes})") + new_fig = axes[0].figure + new_axes = axes + + return new_fig, new_axes + + +def _get_axes_shape( + n_axes: int, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, +) -> Tuple[int, int]: + """Get the number of rows and columns of the subplots.""" + if ( + (n_rows is not None and n_cols is not None) + and ((n_rows * n_cols) < n_axes) + ): + raise ValueError( + f"The number of rows ({n_rows}) multiplied by " + f"the number of columns ({n_cols}) " + f"is less than the number of required " + f"axes ({n_axes})", + ) if n_rows is None and n_cols is None: - n_cols = int(math.ceil(math.sqrt(n_axes))) - n_rows = int(math.ceil(n_axes / n_cols)) + new_n_cols = int(math.ceil(math.sqrt(n_axes))) + new_n_rows = int(math.ceil(n_axes / new_n_cols)) elif n_rows is None and n_cols is not None: - n_rows = int(math.ceil(n_axes / n_cols)) + new_n_cols = n_cols + new_n_rows = int(math.ceil(n_axes / n_cols)) elif n_cols is None and n_rows is not None: - n_cols = int(math.ceil(n_axes / n_rows)) + new_n_cols = int(math.ceil(n_axes / n_rows)) + new_n_rows = n_rows + + return new_n_rows, new_n_cols - return n_rows, n_cols +def _projection_from_dim(dim: int) -> str: -def _set_figure_layout(fig=None, axes=None, - dim=2, n_axes=1, - n_rows=None, n_cols=None): - """Set the figure axes for plotting. + if dim == 2: + return 'rectilinear' + elif dim == 3: + return '3d' + + raise NotImplementedError( + "Only bidimensional or tridimensional plots are supported.", + ) + + +def _set_figure_layout( + fig: Figure, + axes: Sequence[Axes], + dim: Union[int, Sequence[int]] = 2, + n_axes: int = 1, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, +) -> Tuple[Figure, Sequence[Axes]]: + """ + Set the figure axes for plotting. Args: - dim (int): dimension of the plot. Either 2 for a 2D plot or - 3 for a 3D plot. - n_axes (int): Number of subplots. - fig (figure object): figure over with the graphs are - plotted in case ax is not specified. - ax (list of axis objects): axis over where the graphs are - plotted. - n_rows (int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Can only be passed - if no axes are specified. - n_cols (int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Can only be - passed if no axes are specified. + fig: Figure over with the graphs are plotted in case ax is not + specified. + axes: Axis over where the graphs are plotted. + dim: Dimension of the plot. Either 2 for a 2D plot or 3 for a 3D plot. + n_axes: Number of subplots. + n_rows: Designates the number of rows of the figure to plot the + different dimensions of the image. Can only be passed if no axes + are specified. + n_cols: Designates the number of columns of the figure to plot the + different dimensions of the image. Can only be passed if no axes + are specified. Returns: (tuple): tuple containing: @@ -122,91 +183,80 @@ def _set_figure_layout(fig=None, axes=None, * axes (list): axes in which the graphs are plotted. """ - if not (1 < dim < 4): - raise NotImplementedError("Only bidimensional or tridimensional " - "plots are supported.") - - if len(axes) != 0 and len(axes) != n_axes: - raise ValueError(f"The number of axes must be 0 (to create them) or " - f"equal to the number of axes needed " - f"({n_axes} in this case).") + if len(axes) not in {0, n_axes}: + raise ValueError( + f"The number of axes ({len(axes)}) must be 0 (to create them)" + f" or equal to the number of axes needed " + f"({n_axes} in this case).", + ) if len(axes) != 0 and (n_rows is not None or n_cols is not None): - raise ValueError("The number of columns and/or number of rows of " - "the figure, in which each dimension of the " - "image is plotted, can only be customized in case " - "that no axes are provided.") - - if dim == 2: - projection = 'rectilinear' - else: - projection = '3d' + raise ValueError( + "The number of columns and/or number of rows of " + "the figure, in which each dimension of the " + "image is plotted, can only be customized in case " + "that no axes are provided.", + ) if len(axes) == 0: # Create the axes n_rows, n_cols = _get_axes_shape(n_axes, n_rows, n_cols) - fig.subplots(nrows=n_rows, ncols=n_cols, - subplot_kw={"projection": projection}) + + for i in range(n_rows): + for j in range(n_cols): + subplot_index = i * n_cols + j + if subplot_index < n_axes: + plot_dim = ( + dim if isinstance(dim, int) else dim[subplot_index] + ) + + fig.add_subplot( + n_rows, + n_cols, + subplot_index + 1, + projection=_projection_from_dim(plot_dim), + ) + axes = fig.axes else: # Check that the projections are right - - if not all(a.name == projection for a in axes): - raise ValueError(f"The projection of the axes should be " - f"{projection}") + projections = ( + repeat(_projection_from_dim(dim)) + if isinstance(dim, int) + else (_projection_from_dim(d) for d in dim) + ) + + for a, proj in zip(axes, projections): + if a.name != proj: + raise ValueError( + f"The projection of the axes is {a.name} " + f"but should be {proj}", + ) return fig, axes -def _set_figure_layout_for_fdata(fdata, fig=None, axes=None, - n_rows=None, n_cols=None): - """Set the figure axes for plotting a - :class:`~skfda.representation.FData` object. - - Args: - fdata (FData): functional data object. - fig (figure object): figure over with the graphs are - plotted in case ax is not specified. - ax (list of axis objects): axis over where the graphs are - plotted. - n_rows (int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Can only be passed - if no axes are specified. - n_cols (int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Can only be - passed if no axes are specified. - - Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * axes (list): axes in which the graphs are plotted. - - """ - return _set_figure_layout(fig, axes, - dim=fdata.dim_domain + 1, - n_axes=fdata.dim_codomain, - n_rows=n_rows, n_cols=n_cols) - - -def _set_labels(fdata, fig=None, axes=None, patches=None): +def _set_labels( + fdata: FData, + fig: Figure, + axes: Sequence[Axes], + patches: Optional[Sequence[matplotlib.patches.Patch]] = None, +) -> None: """Set labels if any. Args: - fdata (FData): functional data object. - fig (figure object): figure object containing the axes that - implement set_xlabel and set_ylabel, and set_zlabel in case + fdata: functional data object. + fig: figure object containing the axes that implement + set_xlabel and set_ylabel, and set_zlabel in case of a 3d projection. - axes (list of axes): axes objects that implement set_xlabel and - set_ylabel, and set_zlabel in case of a 3d projection; used if + axes: axes objects that implement set_xlabel and set_ylabel, + and set_zlabel in case of a 3d projection; used if fig is None. - patches (list of mpatches.Patch); objects used to generate each - entry in the legend. + patches: objects used to generate each entry in the legend. """ - # Dataset name if fdata.dataset_name is not None: fig.suptitle(fdata.dataset_name) @@ -217,15 +267,17 @@ def _set_labels(fdata, fig=None, axes=None, patches=None): elif patches is not None: axes[0].legend(handles=patches) + assert len(axes) == fdata.dim_codomain + # Axis labels if axes[0].name == '3d': - for i in range(fdata.dim_codomain): + for i, a in enumerate(axes): if fdata.argument_names[0] is not None: - axes[i].set_xlabel(fdata.argument_names[0]) + a.set_xlabel(fdata.argument_names[0]) if fdata.argument_names[1] is not None: - axes[i].set_ylabel(fdata.argument_names[1]) + a.set_ylabel(fdata.argument_names[1]) if fdata.coordinate_names[i] is not None: - axes[i].set_zlabel(fdata.coordinate_names[i]) + a.set_zlabel(fdata.coordinate_names[i]) else: for i in range(fdata.dim_codomain): if fdata.argument_names[0] is not None: @@ -234,16 +286,18 @@ def _set_labels(fdata, fig=None, axes=None, patches=None): axes[i].set_ylabel(fdata.coordinate_names[i]) -def _change_luminosity(color, amount=0.5): +def _change_luminosity(color: ColorLike, amount: float = 0.5) -> ColorLike: """ - Changes the given color luminosity by the given amount. + Change the given color luminosity by the given amount. + Input can be matplotlib color string, hex string, or RGB tuple. Note: Based on https://stackoverflow.com/a/49601444/2455333 """ - import matplotlib.colors as mc import colorsys + + import matplotlib.colors as mc try: c = mc.cnames[color] except TypeError: @@ -263,9 +317,9 @@ def _change_luminosity(color, amount=0.5): return colorsys.hls_to_rgb(c[0], new_lightness, c[2]) -def _darken(color, amount=0): +def _darken(color: ColorLike, amount: float = 0) -> ColorLike: return _change_luminosity(color, 0.5 - amount / 2) -def _lighten(color, amount=0): +def _lighten(color: ColorLike, amount: float = 0) -> ColorLike: return _change_luminosity(color, 0.5 + amount / 2) diff --git a/skfda/exploratory/visualization/clustering.py b/skfda/exploratory/visualization/clustering.py index 21af30129..603bbd7c5 100644 --- a/skfda/exploratory/visualization/clustering.py +++ b/skfda/exploratory/visualization/clustering.py @@ -1,278 +1,127 @@ """Clustering Plots Module.""" -from matplotlib.ticker import MaxNLocator -from mpldatacursor import datacursor -from sklearn.exceptions import NotFittedError +from __future__ import annotations -from sklearn.utils.validation import check_is_fitted +from typing import Optional, Sequence, Tuple, Union + +import matplotlib import matplotlib.patches as mpatches import matplotlib.pyplot as plt import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.collections import PatchCollection +from matplotlib.figure import Figure +from matplotlib.patches import Rectangle +from matplotlib.ticker import MaxNLocator +from sklearn.exceptions import NotFittedError +from sklearn.utils.validation import check_is_fitted +from typing_extensions import Protocol -from ...ml.clustering import FuzzyCMeans -from ._utils import (_darken, - _get_figure_and_axes, _set_figure_layout_for_fdata, - _set_figure_layout, _set_labels) +from ..._utils import _check_compatible_fdata +from ...representation import FData, FDataGrid +from ...representation._typing import NDArrayFloat, NDArrayInt +from ._baseplot import BasePlot +from ._utils import ColorLike, _darken, _set_labels -__author__ = "Amanda Hernando Bernabé" -__email__ = "amanda.hernando@estudiante.uam.es" +class ClusteringEstimator(Protocol): + @property + def n_clusters(self) -> int: + pass -def _check_if_estimator(estimator): - """Checks the argument *estimator* is actually an estimator that - implements the *fit* method. + @property + def cluster_centers_(self) -> FDataGrid: + pass - Args: - estimator (BaseEstimator object): estimator used to calculate the - clusters. - """ - msg = ("This %(name)s instance has no attribute \"fit\".") - if not hasattr(estimator, "fit"): - raise AttributeError(msg % {'name': type(estimator).__name__}) + @property + def labels_(self) -> NDArrayInt: + pass + def fit(self, X: FDataGrid) -> ClusteringEstimator: + pass -def _plot_clustering_checks(estimator, fdata, sample_colors, sample_labels, - cluster_colors, cluster_labels, - center_colors, center_labels): - """Checks the arguments *sample_colors*, *sample_labels*, *cluster_colors*, - *cluster_labels*, *center_colors*, *center_labels*, passed to the plot - functions, have the correct dimensions. + def predict(self, X: FDataGrid) -> NDArrayInt: + pass - Args: - estimator (BaseEstimator object): estimator used to calculate the - clusters. - fdata (FData object): contains the samples which are grouped - into different clusters. - sample_colors (list of colors): contains in order the colors of each - sample of the fdatagrid. - sample_labels (list of str): contains in order the labels of each - sample of the fdatagrid. - cluster_colors (list of colors): contains in order the colors of each - cluster the samples of the fdatagrid are classified into. - cluster_labels (list of str): contains in order the names of each - cluster the samples of the fdatagrid are classified into. - center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are - classified into. - center_labels list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are - classified into. - """ +class FuzzyClusteringEstimator(ClusteringEstimator, Protocol): - if sample_colors is not None and len( - sample_colors) != fdata.n_samples: - raise ValueError( - "sample_colors must contain a color for each sample.") + def predict_proba(self, X: FDataGrid) -> NDArrayFloat: + pass - if sample_labels is not None and len( - sample_labels) != fdata.n_samples: - raise ValueError( - "sample_labels must contain a label for each sample.") - if cluster_colors is not None and len( - cluster_colors) != estimator.n_clusters: +def _plot_clustering_checks( + estimator: ClusteringEstimator, + fdata: FData, + sample_colors: Optional[Sequence[ColorLike]], + sample_labels: Optional[Sequence[str]], + cluster_colors: Optional[Sequence[ColorLike]], + cluster_labels: Optional[Sequence[str]], + center_colors: Optional[Sequence[ColorLike]], + center_labels: Optional[Sequence[str]], +) -> None: + """Check the arguments.""" + if ( + sample_colors is not None + and len(sample_colors) != fdata.n_samples + ): raise ValueError( - "cluster_colors must contain a color for each cluster.") + "sample_colors must contain a color for each sample.", + ) - if cluster_labels is not None and len( - cluster_labels) != estimator.n_clusters: + if ( + sample_labels is not None + and len(sample_labels) != fdata.n_samples + ): raise ValueError( - "cluster_labels must contain a label for each cluster.") + "sample_labels must contain a label for each sample.", + ) - if center_colors is not None and len( - center_colors) != estimator.n_clusters: + if ( + cluster_colors is not None + and len(cluster_colors) != estimator.n_clusters + ): raise ValueError( - "center_colors must contain a color for each center.") + "cluster_colors must contain a color for each cluster.", + ) - if center_labels is not None and len( - center_labels) != estimator.n_clusters: + if ( + cluster_labels is not None + and len(cluster_labels) != estimator.n_clusters + ): raise ValueError( - "centers_labels must contain a label for each center.") - + "cluster_labels must contain a label for each cluster.", + ) -def _plot_clusters(estimator, fdata, *, chart=None, fig=None, axes=None, - n_rows=None, n_cols=None, - labels, sample_labels, cluster_colors, cluster_labels, - center_colors, center_labels, center_width, colormap): - """Implementation of the plot of the FDataGrid samples by clusters. - - Args: - estimator (BaseEstimator object): estimator used to calculate the - clusters. - fdatagrid (FDataGrd object): contains the samples which are grouped - into different clusters. - fig (figure object): figure over which the graphs are plotted in - case ax is not specified. If None and ax is also None, the figure - is initialized. - axes (list of axes objects): axes over where the graphs are plotted. - If None, see param fig. - n_rows(int): designates the number of rows of the figure to plot the - different dimensions of the image. Only specified if fig and - ax are None. - n_cols(int): designates the number of columns of the figure to plot - the different dimensions of the image. Only specified if fig - and ax are None. - labels (numpy.ndarray, int: (n_samples, dim_codomain)): 2-dimensional - matrix where each row contains the number of cluster cluster - that observation belongs to. - sample_labels (list of str): contains in order the labels of each - sample of the fdatagrid. - cluster_colors (list of colors): contains in order the colors of each - cluster the samples of the fdatagrid are classified into. - cluster_labels (list of str): contains in order the names of each - cluster the samples of the fdatagrid are classified into. - center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are - classified into. - center_labels list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are - classified into. - center_width (int): width of the centroids. - colormap(colormap): colormap from which the colors of the plot are - taken. + if ( + center_colors is not None + and len(center_colors) != estimator.n_clusters + ): + raise ValueError( + "center_colors must contain a color for each center.", + ) - Returns: - (tuple): tuple containing: + if ( + center_labels is not None + and len(center_labels) != estimator.n_clusters + ): + raise ValueError( + "centers_labels must contain a label for each center.", + ) - fig (figure object): figure object in which the graphs are plotted - in case ax is None. - ax (axes object): axes in which the graphs are plotted. +def _get_labels( + x_label: Optional[str], + y_label: Optional[str], + title: Optional[str], + xlabel_str: str, +) -> Tuple[str, str, str]: """ - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) - - _plot_clustering_checks(estimator, fdata, None, sample_labels, - cluster_colors, cluster_labels, center_colors, - center_labels) - - if sample_labels is None: - sample_labels = [f'$SAMPLE: {i}$' for i in range(fdata.n_samples)] - - if cluster_colors is None: - cluster_colors = colormap( - np.arange(estimator.n_clusters) / (estimator.n_clusters - 1)) - - if cluster_labels is None: - cluster_labels = [ - f'$CLUSTER: {i}$' for i in range(estimator.n_clusters)] - - if center_colors is None: - center_colors = [_darken(c, 0.5) for c in cluster_colors] - - if center_labels is None: - center_labels = [ - f'$CENTER: {i}$' for i in range(estimator.n_clusters)] - - colors_by_cluster = cluster_colors[labels] - - patches = [] - for i in range(estimator.n_clusters): - patches.append( - mpatches.Patch(color=cluster_colors[i], - label=cluster_labels[i])) - - for j in range(fdata.dim_codomain): - for i in range(fdata.n_samples): - axes[j].plot(fdata.grid_points[0], - fdata.data_matrix[i, :, j], - c=colors_by_cluster[i], - label=sample_labels[i]) - for i in range(estimator.n_clusters): - axes[j].plot(fdata.grid_points[0], - estimator.cluster_centers_.data_matrix[i, :, j], - c=center_colors[i], - label=center_labels[i], - linewidth=center_width) - axes[j].legend(handles=patches) - datacursor(formatter='{label}'.format) - - _set_labels(fdata, fig, axes) - - return fig - - -def plot_clusters(estimator, X, chart=None, fig=None, axes=None, - n_rows=None, n_cols=None, - sample_labels=None, cluster_colors=None, - cluster_labels=None, center_colors=None, - center_labels=None, - center_width=3, - colormap=plt.cm.get_cmap('rainbow')): - """Plot of the FDataGrid samples by clusters. - - The clusters are calculated with the estimator passed as a parameter. If - the estimator is not fitted, the fit method is called. - Once each sample is assigned a label the plotting can be done. - Each group is assigned a color described in a leglend. - - Args: - estimator (BaseEstimator object): estimator used to calculate the - clusters. - X (FDataGrd object): contains the samples which are grouped - into different clusters. - fig (figure object): figure over which the graphs are plotted in - case ax is not specified. If None and ax is also None, the figure - is initialized. - axes (list of axis objects): axis over where the graphs are plotted. - If None, see param fig. - n_rows (int): designates the number of rows of the figure to plot the - different dimensions of the image. Only specified if fig and - ax are None. - n_cols (int): designates the number of columns of the figure to plot - the different dimensions of the image. Only specified if fig - and ax are None. - sample_labels (list of str): contains in order the labels of each - sample of the fdatagrid. - cluster_colors (list of colors): contains in order the colors of each - cluster the samples of the fdatagrid are classified into. - cluster_labels (list of str): contains in order the names of each - cluster the samples of the fdatagrid are classified into. - center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are - classified into. - center_labels (list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are - classified into. - center_width (int): width of the centroid curves. - colormap(colormap): colormap from which the colors of the plot are - taken. Defaults to `rainbow`. - - Returns: - (tuple): tuple containing: - - fig (figure object): figure object in which the graphs are plotted - in case ax is None. + Get the axes labels. - ax (axes object): axes in which the graphs are plotted. - """ - _check_if_estimator(estimator) - try: - check_is_fitted(estimator) - estimator._check_test_data(X) - except NotFittedError: - estimator.fit(X) - - if isinstance(estimator, FuzzyCMeans): - labels = np.argmax(estimator.labels_, axis=1) - else: - labels = estimator.labels_ - - return _plot_clusters(estimator=estimator, fdata=X, - fig=fig, axes=axes, n_rows=n_rows, n_cols=n_cols, - labels=labels, sample_labels=sample_labels, - cluster_colors=cluster_colors, - cluster_labels=cluster_labels, - center_colors=center_colors, - center_labels=center_labels, - center_width=center_width, - colormap=colormap) - - -def _get_labels(x_label, y_label, title, xlabel_str): - """Sets the arguments *xlabel*, *ylabel*, *title* passed to the plot + Set the arguments *xlabel*, *ylabel*, *title* passed to the plot functions :func:`plot_cluster_lines ` and :func:`plot_cluster_bars @@ -280,17 +129,17 @@ def _get_labels(x_label, y_label, title, xlabel_str): in case they are not set yet. Args: - xlabel (lstr): Label for the x-axes. - ylabel (str): Label for the y-axes. - title (str): Title for the figure where the clustering results are + x_label: Label for the x-axes. + y_label: Label for the y-axes. + title: Title for the figure where the clustering results are ploted. - xlabel_str (str): In case xlabel is None, string to use for the labels + xlabel_str: In case xlabel is None, string to use for the labels in the x-axes. Returns: - xlabel (str): Labels for the x-axes. - ylabel (str): Labels for the y-axes. - title (str): Title for the figure where the clustering results are + xlabel: Labels for the x-axes. + ylabel: Labels for the y-axes. + title: Title for the figure where the clustering results are plotted. """ if x_label is None: @@ -305,222 +154,525 @@ def _get_labels(x_label, y_label, title, xlabel_str): return x_label, y_label, title -def plot_cluster_lines(estimator, X, chart=None, fig=None, axes=None, - sample_colors=None, sample_labels=None, - cluster_labels=None, - colormap=plt.cm.get_cmap('rainbow'), - x_label=None, y_label=None, title=None): - """Implementation of the plotting of the results of the - :func:`Fuzzy K-Means ` method. +class ClusterPlot(BasePlot): + """ + ClusterPlot class. + Args: + estimator: estimator used to calculate the + clusters. + X: contains the samples which are grouped + into different clusters. + fig: figure over which the graphs are plotted in + case ax is not specified. If None and ax is also None, the figure + is initialized. + axes: axis over where the graphs are plotted. + If None, see param fig. + n_rows: designates the number of rows of the figure to plot the + different dimensions of the image. Only specified if fig and + ax are None. + n_cols: designates the number of columns of the figure to plot + the different dimensions of the image. Only specified if fig + and ax are None. + sample_labels: contains in order the labels of each + sample of the fdatagrid. + cluster_colors: contains in order the colors of each + cluster the samples of the fdatagrid are classified into. + cluster_labels: contains in order the names of each + cluster the samples of the fdatagrid are classified into. + center_colors: contains in order the colors of each + centroid of the clusters the samples of the fdatagrid are + classified into. + center_labels: contains in order the labels of each + centroid of the clusters the samples of the fdatagrid are + classified into. + center_width: width of the centroid curves. + colormap: colormap from which the colors of the plot are + taken. Defaults to `rainbow`. + """ - A kind of Parallel Coordinates plot is generated in this function with the - membership values obtained from the algorithm. A line is plotted for each - sample with the values for each cluster. See `Clustering Example - <../auto_examples/plot_clustering.html>`_. + def __init__( + self, + estimator: ClusteringEstimator, + fdata: FDataGrid, + chart: Union[Figure, Axes, None] = None, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + sample_labels: Optional[Sequence[str]] = None, + cluster_colors: Optional[Sequence[ColorLike]] = None, + cluster_labels: Optional[Sequence[str]] = None, + center_colors: Optional[Sequence[ColorLike]] = None, + center_labels: Optional[Sequence[str]] = None, + center_width: int = 3, + colormap: matplotlib.colors.Colormap = None, + ) -> None: + + if colormap is None: + colormap = plt.cm.get_cmap('rainbow') + + super().__init__( + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + ) + self.fdata = fdata + self.estimator = estimator + self.sample_labels = sample_labels + self.cluster_colors = cluster_colors + self.cluster_labels = cluster_labels + self.center_colors = center_colors + self.center_labels = center_labels + self.center_width = center_width + self.colormap = colormap + + @property + def n_subplots(self) -> int: + return self.fdata.dim_codomain + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot_clusters( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + """Implement the plot of the FDataGrid samples by clusters.""" + _plot_clustering_checks( + estimator=self.estimator, + fdata=self.fdata, + sample_colors=None, + sample_labels=self.sample_labels, + cluster_colors=self.cluster_colors, + cluster_labels=self.cluster_labels, + center_colors=self.center_colors, + center_labels=self.center_labels, + ) + + if self.sample_labels is None: + self.sample_labels = [ + f'$SAMPLE: {i}$' for i in range(self.fdata.n_samples) + ] + + if self.cluster_colors is None: + self.cluster_colors = self.colormap( + np.arange(self.estimator.n_clusters) + / (self.estimator.n_clusters - 1), + ) + + if self.cluster_labels is None: + self.cluster_labels = [ + f'$CLUSTER: {i}$' for i in range(self.estimator.n_clusters) + ] + + if self.center_colors is None: + self.center_colors = [_darken(c, 0.5) for c in self.cluster_colors] + + if self.center_labels is None: + self.center_labels = [ + f'$CENTER: {i}$' for i in range(self.estimator.n_clusters) + ] + + colors_by_cluster = self.cluster_colors[self.labels] + + patches = [ + mpatches.Patch( + color=self.cluster_colors[i], + label=self.cluster_labels[i], + ) + for i in range(self.estimator.n_clusters) + ] + + artists = [ + axes[j].plot( + self.fdata.grid_points[0], + self.fdata.data_matrix[i, :, j], + c=colors_by_cluster[i], + label=self.sample_labels[i], + ) + for j in range(self.fdata.dim_codomain) + for i in range(self.fdata.n_samples) + ] + + self.artists = np.array(artists).reshape( + (self.n_subplots, self.n_samples), + ).T + + for j in range(self.fdata.dim_codomain): + + for i in range(self.estimator.n_clusters): + axes[j].plot( + self.fdata.grid_points[0], + self.estimator.cluster_centers_.data_matrix[i, :, j], + c=self.center_colors[i], + label=self.center_labels[i], + linewidth=self.center_width, + ) + axes[j].legend(handles=patches) + + _set_labels(self.fdata, fig, axes) + + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + + try: + check_is_fitted(self.estimator) + _check_compatible_fdata( + self.estimator.cluster_centers_, + self.fdata, + ) + except NotFittedError: + self.estimator.fit(self.fdata) + + self.labels = self.estimator.labels_ + + self._plot_clusters(fig=fig, axes=axes) + + +class ClusterMembershipLinesPlot(BasePlot): + """ + Class ClusterMembershipLinesPlot. Args: - estimator (BaseEstimator object): estimator used to calculate the + estimator: estimator used to calculate the clusters. - X (FDataGrd object): contains the samples which are grouped + X: contains the samples which are grouped into different clusters. - fig (figure object, optional): figure over which the graph is + fig: figure over which the graph is plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - axes (axes object, optional): axis over where the graph is plotted. + axes: axis over where the graph is plotted. If None, see param fig. - sample_colors (list of colors, optional): contains in order the colors + sample_colors: contains in order the colors of each sample of the fdatagrid. - sample_labels (list of str, optional): contains in order the labels + sample_labels: contains in order the labels of each sample of the fdatagrid. - cluster_labels (list of str, optional): contains in order the names of + cluster_labels: contains in order the names of each cluster the samples of the fdatagrid are classified into. - colormap(colormap, optional): colormap from which the colors of the + colormap: colormap from which the colors of the plot are taken. - x_label (str): Label for the x-axis. Defaults to "Cluster". - y_label (str): Label for the y-axis. Defaults to + x_label: Label for the x-axis. Defaults to "Cluster". + y_label: Label for the y-axis. Defaults to "Degree of membership". - title (str, optional): Title for the figure where the clustering + title: Title for the figure where the clustering results are ploted. Defaults to "Degrees of membership of the samples to each cluster". + """ - Returns: - (tuple): tuple containing: - - fig (figure object): figure object in which the graphs are plotted - in case ax is None. - - ax (axes object): axes in which the graphs are plotted. - + def __init__( + self, + estimator: FuzzyClusteringEstimator, + fdata: FDataGrid, + *, + chart: Union[Figure, Axes, None] = None, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, + sample_colors: Optional[Sequence[ColorLike]] = None, + sample_labels: Optional[Sequence[str]] = None, + cluster_labels: Optional[Sequence[str]] = None, + colormap: matplotlib.colors.Colormap = None, + x_label: Optional[str] = None, + y_label: Optional[str] = None, + title: Optional[str] = None, + ) -> None: + + if colormap is None: + colormap = plt.cm.get_cmap('rainbow') + + super().__init__( + chart, + fig=fig, + axes=axes, + ) + self.fdata = fdata + self.estimator = estimator + self.sample_labels = sample_labels + self.sample_colors = sample_colors + self.cluster_labels = cluster_labels + self.x_label = x_label + self.y_label = y_label + self.title = title + self.colormap = colormap + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + + try: + check_is_fitted(self.estimator) + _check_compatible_fdata( + self.estimator.cluster_centers_, + self.fdata, + ) + except NotFittedError: + self.estimator.fit(self.fdata) + + membership = self.estimator.predict_proba(self.fdata) + + _plot_clustering_checks( + estimator=self.estimator, + fdata=self.fdata, + sample_colors=self.sample_colors, + sample_labels=self.sample_labels, + cluster_colors=None, + cluster_labels=self.cluster_labels, + center_colors=None, + center_labels=None, + ) + + x_label, y_label, title = _get_labels( + self.x_label, + self.y_label, + self.title, + "Cluster", + ) + + if self.sample_colors is None: + self.cluster_colors = self.colormap( + np.arange(self.estimator.n_clusters) + / (self.estimator.n_clusters - 1), + ) + labels_by_cluster = self.estimator.labels_ + self.sample_colors = self.cluster_colors[labels_by_cluster] + + if self.sample_labels is None: + self.sample_labels = [ + f'$SAMPLE: {i}$' + for i in range(self.fdata.n_samples) + ] + + if self.cluster_labels is None: + self.cluster_labels = [ + f'${i}$' + for i in range(self.estimator.n_clusters) + ] + + axes[0].get_xaxis().set_major_locator(MaxNLocator(integer=True)) + self.artists = np.array([ + axes[0].plot( + np.arange(self.estimator.n_clusters), + membership[i], + label=self.sample_labels[i], + color=self.sample_colors[i], + ) + for i in range(self.fdata.n_samples) + ]) + + axes[0].set_xticks(np.arange(self.estimator.n_clusters)) + axes[0].set_xticklabels(self.cluster_labels) + axes[0].set_xlabel(x_label) + axes[0].set_ylabel(y_label) + + fig.suptitle(title) + + +class ClusterMembershipPlot(BasePlot): """ - fdata = X - _check_if_estimator(estimator) - - if not isinstance(estimator, FuzzyCMeans): - raise ValueError("The estimator must be a FuzzyCMeans object.") - - try: - check_is_fitted(estimator) - estimator._check_test_data(X) - except NotFittedError: - estimator.fit(X) - - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout(fig, axes) - - _plot_clustering_checks(estimator, fdata, sample_colors, sample_labels, - None, cluster_labels, None, None) - - x_label, y_label, title = _get_labels(x_label, y_label, title, "Cluster") - - if sample_colors is None: - cluster_colors = colormap(np.arange(estimator.n_clusters) / - (estimator.n_clusters - 1)) - labels_by_cluster = np.argmax(estimator.labels_, axis=1) - sample_colors = cluster_colors[labels_by_cluster] - - if sample_labels is None: - sample_labels = ['$SAMPLE: {}$'.format(i) for i in - range(fdata.n_samples)] - - if cluster_labels is None: - cluster_labels = ['${}$'.format(i) for i in - range(estimator.n_clusters)] - - axes[0].get_xaxis().set_major_locator(MaxNLocator(integer=True)) - for i in range(fdata.n_samples): - axes[0].plot(np.arange(estimator.n_clusters), - estimator.labels_[i], - label=sample_labels[i], - color=sample_colors[i]) - axes[0].set_xticks(np.arange(estimator.n_clusters)) - axes[0].set_xticklabels(cluster_labels) - axes[0].set_xlabel(x_label) - axes[0].set_ylabel(y_label) - datacursor(formatter='{label}'.format) - - fig.suptitle(title) - return fig - - -def plot_cluster_bars(estimator, X, chart=None, fig=None, axes=None, sort=-1, - sample_labels=None, cluster_colors=None, - cluster_labels=None, colormap=plt.cm.get_cmap('rainbow'), - x_label=None, y_label=None, title=None): - """Implementation of the plotting of the results of the - :func:`Fuzzy K-Means ` method. - - - A kind of barplot is generated in this function with the membership values - obtained from the algorithm. There is a bar for each sample whose height is - 1 (the sum of the membership values of a sample add to 1), and the part - proportional to each cluster is coloured with the corresponding color. See - `Clustering Example <../auto_examples/plot_clustering.html>`_. + Class ClusterMembershipPlot. Args: - estimator (BaseEstimator object): estimator used to calculate the + estimator: estimator used to calculate the clusters. - X (FDataGrd object): contains the samples which are grouped + X: contains the samples which are grouped into different clusters. - fig (figure object, optional): figure over which the graph is + fig: figure over which the graph is plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - axes (axes object, optional): axes over where the graph is plotted. + axes: axis over where the graph is plotted. If None, see param fig. - sort(int, optional): Number in the range [-1, n_clusters) designating - the cluster whose labels are sorted in a decrementing order. - Defaults to -1, in this case, no sorting is done. - sample_labels (list of str, optional): contains in order the labels + sample_colors: contains in order the colors + of each sample of the fdatagrid. + sample_labels: contains in order the labels of each sample of the fdatagrid. - cluster_labels (list of str, optional): contains in order the names of + cluster_labels: contains in order the names of each cluster the samples of the fdatagrid are classified into. - cluster_colors (list of colors): contains in order the colors of each - cluster the samples of the fdatagrid are classified into. - colormap(colormap, optional): colormap from which the colors of the + colormap: colormap from which the colors of the plot are taken. - x_label (str): Label for the x-axis. Defaults to "Sample". - y_label (str): Label for the y-axis. Defaults to + x_label: Label for the x-axis. Defaults to "Cluster". + y_label: Label for the y-axis. Defaults to "Degree of membership". - title (str): Title for the figure where the clustering results are - plotted. + title: Title for the figure where the clustering + results are ploted. Defaults to "Degrees of membership of the samples to each cluster". - - Returns: - (tuple): tuple containing: - - fig (figure object): figure object in which the graph is plotted - in case ax is None. - - ax (axis object): axis in which the graph is plotted. - """ - fdata = X - _check_if_estimator(estimator) - if not isinstance(estimator, FuzzyCMeans): - raise ValueError("The estimator must be a FuzzyCMeans object.") - - try: - check_is_fitted(estimator) - estimator._check_test_data(X) - except NotFittedError: - estimator.fit(X) - - if sort < -1 or sort >= estimator.n_clusters: - raise ValueError( - "The sorting number must belong to the interval [-1, n_clusters)") - - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout(fig, axes) - - _plot_clustering_checks(estimator, fdata, None, sample_labels, - cluster_colors, cluster_labels, None, None) - - x_label, y_label, title = _get_labels(x_label, y_label, title, "Sample") - - if sample_labels is None: - sample_labels = np.arange(fdata.n_samples) - - if cluster_colors is None: - cluster_colors = colormap( - np.arange(estimator.n_clusters) / (estimator.n_clusters - 1)) - - if cluster_labels is None: - cluster_labels = [f'$CLUSTER: {i}$' for i in - range(estimator.n_clusters)] - - patches = [] - for i in range(estimator.n_clusters): - patches.append( - mpatches.Patch(color=cluster_colors[i], label=cluster_labels[i])) - - if sort != -1: - sample_indices = np.argsort(-estimator.labels_[:, sort]) - sample_labels = np.copy(sample_labels[sample_indices]) - labels_dim = np.copy(estimator.labels_[sample_indices]) - - temp_labels = np.copy(labels_dim[:, 0]) - labels_dim[:, 0] = labels_dim[:, sort] - labels_dim[:, sort] = temp_labels - - temp_color = np.copy(cluster_colors[0]) - cluster_colors[0] = cluster_colors[sort] - cluster_colors[sort] = temp_color - else: - labels_dim = estimator.labels_ - - conc = np.zeros((fdata.n_samples, 1)) - labels_dim = np.concatenate((conc, labels_dim), axis=-1) - for i in range(estimator.n_clusters): - axes[0].bar(np.arange(fdata.n_samples), - labels_dim[:, i + 1], - bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), - color=cluster_colors[i]) - axes[0].set_xticks(np.arange(fdata.n_samples)) - axes[0].set_xticklabels(sample_labels) - axes[0].set_xlabel(x_label) - axes[0].set_ylabel(y_label) - axes[0].legend(handles=patches) - - fig.suptitle(title) - return fig + def __init__( + self, + estimator: FuzzyClusteringEstimator, + fdata: FData, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Union[Axes, Sequence[Axes], None] = None, + sort: int = -1, + sample_labels: Optional[Sequence[str]] = None, + cluster_colors: Optional[Sequence[ColorLike]] = None, + cluster_labels: Optional[Sequence[str]] = None, + colormap: matplotlib.colors.Colormap = None, + x_label: Optional[str] = None, + y_label: Optional[str] = None, + title: Optional[str] = None, + ) -> None: + + if colormap is None: + colormap = plt.cm.get_cmap('rainbow') + + super().__init__( + chart, + fig=fig, + axes=axes, + ) + self.fdata = fdata + self.estimator = estimator + self.sample_labels = sample_labels + self.cluster_colors = cluster_colors + self.cluster_labels = cluster_labels + self.x_label = x_label + self.y_label = y_label + self.title = title + self.colormap = colormap + self.sort = sort + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + + self.artists = np.full( + (self.n_samples, self.n_subplots), + None, + dtype=Artist, + ) + + try: + check_is_fitted(self.estimator) + _check_compatible_fdata( + self.estimator.cluster_centers_, + self.fdata, + ) + except NotFittedError: + self.estimator.fit(self.fdata) + + membership = self.estimator.predict_proba(self.fdata) + + if self.sort < -1 or self.sort >= self.estimator.n_clusters: + raise ValueError( + "The sorting number must belong to " + "the interval [-1, n_clusters)", + ) + + _plot_clustering_checks( + estimator=self.estimator, + fdata=self.fdata, + sample_colors=None, + sample_labels=self.sample_labels, + cluster_colors=self.cluster_colors, + cluster_labels=self.cluster_labels, + center_colors=None, + center_labels=None, + ) + + x_label, y_label, title = _get_labels( + self.x_label, + self.y_label, + self.title, + "Sample", + ) + + if self.sample_labels is None: + self.sample_labels = np.arange(self.fdata.n_samples) + + if self.cluster_colors is None: + self.cluster_colors = self.colormap( + np.arange(self.estimator.n_clusters) + / (self.estimator.n_clusters - 1), + ) + + if self.cluster_labels is None: + self.cluster_labels = [ + f'$CLUSTER: {i}$' + for i in range(self.estimator.n_clusters) + ] + + patches = [ + mpatches.Patch( + color=self.cluster_colors[i], + label=self.cluster_labels[i], + ) + for i in range(self.estimator.n_clusters) + ] + + if self.sort == -1: + labels_dim = membership + else: + sample_indices = np.argsort(-membership[:, self.sort]) + self.sample_labels = np.copy(self.sample_labels[sample_indices]) + labels_dim = np.copy(membership[sample_indices]) + + temp_labels = np.copy(labels_dim[:, 0]) + labels_dim[:, 0] = labels_dim[:, self.sort] + labels_dim[:, self.sort] = temp_labels + + temp_color = np.copy(self.cluster_colors[0]) + self.cluster_colors[0] = self.cluster_colors[self.sort] + self.cluster_colors[self.sort] = temp_color + + conc = np.zeros((self.fdata.n_samples, 1)) + labels_dim = np.concatenate((conc, labels_dim), axis=-1) + bars = [ + axes[0].bar( + np.arange(self.fdata.n_samples), + labels_dim[:, i + 1], + bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), + color=self.cluster_colors[i], + ) + for i in range(self.estimator.n_clusters) + ] + + for b in bars: + b.remove() + b.figure = None + + for i in range(self.n_samples): + collection = PatchCollection( + [ + Rectangle( + bar.patches[i].get_xy(), + bar.patches[i].get_width(), + bar.patches[i].get_height(), + color=bar.patches[i].get_facecolor(), + ) for bar in bars + ], + match_original=True, + ) + axes[0].add_collection(collection) + self.artists[i, 0] = collection + + fig.canvas.draw() + + axes[0].set_xticks(np.arange(self.fdata.n_samples)) + axes[0].set_xticklabels(self.sample_labels) + axes[0].set_xlabel(x_label) + axes[0].set_ylabel(y_label) + axes[0].legend(handles=patches) + + fig.suptitle(title) diff --git a/skfda/exploratory/visualization/fpca.py b/skfda/exploratory/visualization/fpca.py index 5edbc7fa8..8da05b16b 100644 --- a/skfda/exploratory/visualization/fpca.py +++ b/skfda/exploratory/visualization/fpca.py @@ -1,79 +1,91 @@ -from matplotlib import pyplot as plt -from skfda.representation import FDataGrid, FDataBasis, FData -from skfda.exploratory.visualization._utils import _get_figure_and_axes +from typing import Optional, Sequence, Union +from matplotlib.axes import Axes +from matplotlib.figure import Figure -def plot_fpca_perturbation_graphs(mean, components, multiple, - chart = None, - fig=None, - axes=None, - **kwargs): - """ Plots the perturbation graphs for the principal components. - The perturbations are defined as variations over the mean. Adding a multiple - of the principal component curve to the mean function results in the - positive perturbation and subtracting a multiple of the principal component - curve results in the negative perturbation. For each principal component - curve passed, a subplot with the mean and the perturbations is shown. +from skfda.exploratory.visualization.representation import GraphPlot +from skfda.representation import FData + +from ._baseplot import BasePlot + + +class FPCAPlot(BasePlot): + """ + FPCAPlot visualization. Args: - mean (FDataGrid or FDataBasis): - the functional data object containing the mean function. + mean: The functional data object containing the mean function. If len(mean) > 1, the mean is computed. - components (FDataGrid or FDataBasis): - the principal components - multiple (float): - multiple of the principal component curve to be added or + components: The principal components + multiple: Multiple of the principal component curve to be added or subtracted. - fig (figure object, optional): - figure over which the graph is plotted. If not specified it will + fig: Figure over which the graph is plotted. If not specified it will be initialized - axes (axes object, optional): axis over where the graph is plotted. - If None, see param fig. - - Returns: - (FDataGrid or FDataBasis): this contains the mean function followed - by the positive perturbation and the negative perturbation. + axes: Axes over where the graph is plotted. + If ``None``, see param fig. + n_rows: Designates the number of rows of the figure. + n_cols: Designates the number of columns of the figure. """ - if len(mean) > 1: - mean = mean.mean() + def __init__( + self, + mean: FData, + components: FData, + multiple: float, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + ): + super().__init__( + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + ) + self.mean = mean + self.components = components + self.multiple = multiple - fig, axes = _get_figure_and_axes(chart, fig, axes) + @property + def n_subplots(self) -> int: + return len(self.components) - if not axes: - axes = fig.subplots(nrows=len(components)) + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: - for i in range(len(axes)): - aux = _get_component_perturbations(mean, components, i, multiple) - aux.plot(axes[i], **kwargs) - axes[i].set_title('Principal component ' + str(i + 1)) + if len(self.mean) > 1: + self.mean = self.mean.mean() - return fig + for i, ax in enumerate(axes): + perturbations = self._get_component_perturbations(i) + GraphPlot(fdata=perturbations, axes=ax).plot() + ax.set_title(f"Principal component {i + 1}") + def _get_component_perturbations(self, index: int = 0) -> FData: + """ + Compute the perturbations over the mean of a principal component. -def _get_component_perturbations(mean, components, index=0, multiple=30): - """ Computes the perturbations over the mean function of a principal - component at a certain index. + Args: + index: Index of the component for which we want to compute the + perturbations - Args: - X (FDataGrid or FDataBasis): - the functional data object from which we obtain the mean - index (int): - index of the component for which we want to compute the - perturbations - multiple (float): - multiple of the principal component curve to be added or - subtracted. - - Returns: - (FDataGrid or FDataBasis): this contains the mean function followed - by the positive perturbation and the negative perturbation. - """ - if not isinstance(mean, FData): - raise AttributeError("X must be a FData object") - perturbations = mean.copy() - perturbations = perturbations.concatenate( - perturbations[0] + multiple * components[index]) - perturbations = perturbations.concatenate( - perturbations[0] - multiple * components[index]) - return perturbations + Returns: + The mean function followed by the positive perturbation and + the negative perturbation. + """ + if not isinstance(self.mean, FData): + raise AttributeError("X must be a FData object") + perturbations = self.mean.copy() + perturbations = perturbations.concatenate( + perturbations[0] + self.multiple * self.components[index], + ) + return perturbations.concatenate( + perturbations[0] - self.multiple * self.components[index], + ) diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py index 7ccb90794..bde4576e9 100644 --- a/skfda/exploratory/visualization/representation.py +++ b/skfda/exploratory/visualization/representation.py @@ -1,30 +1,59 @@ +"""Representation Module. + +This module contains the functionality related +with plotting and scattering our different datasets. +It allows multiple modes and colors, which could +be set manually or automatically depending on values +like depth measures. +""" + +from typing import Any, Mapping, Optional, Sequence, Tuple, TypeVar, Union import matplotlib.cm import matplotlib.patches - import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.colors import Colormap +from matplotlib.figure import Figure +from typing_extensions import Protocol -from ..._utils import _tuple_of_arrays, constants -from ._utils import (_get_figure_and_axes, _set_figure_layout_for_fdata, - _set_labels) +from ... import FDataGrid +from ..._utils import _to_domain_range, constants +from ...representation._functional_data import FData +from ...representation._typing import DomainRangeLike, GridPointsLike +from ._baseplot import BasePlot +from ._utils import ColorLike, _set_labels +K = TypeVar('K', contravariant=True) +V = TypeVar('V', covariant=True) +T = TypeVar('T', FDataGrid, np.ndarray) -def _get_label_colors(n_labels, group_colors=None): - """Get the colors of each label""" - if group_colors is not None: - if len(group_colors) != n_labels: - raise ValueError("There must be a color in group_colors " - "for each of the labels that appear in " - "group.") - else: - colormap = matplotlib.cm.get_cmap() - group_colors = colormap(np.arange(n_labels) / (n_labels - 1)) +class Indexable(Protocol[K, V]): + """Class Indexable used to type _get_color_info.""" + + def __getitem__(self, __key: K) -> V: + pass - return group_colors + def __len__(self) -> int: + pass -def _get_color_info(fdata, group, group_names, group_colors, legend, kwargs): +def _get_color_info( + fdata: T, + group: Optional[Sequence[K]] = None, + group_names: Optional[Indexable[K, str]] = None, + group_colors: Optional[Indexable[K, ColorLike]] = None, + legend: bool = False, + kwargs: Optional[Mapping[str, Any]] = None, +) -> Tuple[ + Optional[ColorLike], + Optional[Sequence[matplotlib.patches.Patch]], +]: + + if kwargs is None: + kwargs = {} patches = None @@ -37,13 +66,15 @@ def _get_color_info(fdata, group, group_names, group_colors, legend, kwargs): if group_colors is not None: group_colors_array = np.array( - [group_colors[g] for g in group_unique]) + [group_colors[g] for g in group_unique], + ) else: prop_cycle = matplotlib.rcParams['axes.prop_cycle'] cycle_colors = prop_cycle.by_key()['color'] group_colors_array = np.take( - cycle_colors, np.arange(n_labels), mode='wrap') + cycle_colors, np.arange(n_labels), mode='wrap', + ) sample_colors = group_colors_array[group_indexes] @@ -51,13 +82,16 @@ def _get_color_info(fdata, group, group_names, group_colors, legend, kwargs): if group_names is not None: group_names_array = np.array( - [group_names[g] for g in group_unique]) + [group_names[g] for g in group_unique], + ) elif legend is True: group_names_array = group_unique if group_names_array is not None: - patches = [matplotlib.patches.Patch(color=c, label=l) - for c, l in zip(group_colors_array, group_names_array)] + patches = [ + matplotlib.patches.Patch(color=c, label=l) + for c, l in zip(group_colors_array, group_names_array) + ] else: # In this case, each curve has a different color unless specified @@ -77,249 +111,452 @@ def _get_color_info(fdata, group, group_names, group_colors, legend, kwargs): return sample_colors, patches -def plot_graph(fdata, chart=None, *, fig=None, axes=None, - n_rows=None, n_cols=None, n_points=None, - domain_range=None, - group=None, group_colors=None, group_names=None, - legend: bool = False, - **kwargs): - """Plot the FDatGrid object graph as hypersurfaces. - - Plots each coordinate separately. If the :term:`domain` is one dimensional, - the plots will be curves, and if it is two dimensional, they will be - surfaces. +class GraphPlot(BasePlot): + """ + Class used to plot the FDataGrid object graph as hypersurfaces. + When plotting functional data, we can either choose manually a color, + a group of colors for the representations. Besides, we can use a list of + variables (depths, scalar regression targets...) can be used as an + argument to display the functions wtih a gradient of colors. Args: - chart (figure object, axe or list of axes, optional): figure over + fdata: functional data set that we want to plot. + gradient_criteria: list of real values used to determine the color + in which each of the instances will be plotted. + max_grad: maximum value that the gradient_list can take, it will be + used to normalize the ``gradient_criteria`` in order to get values + that can be used in the function colormap.__call__(). If not + declared it will be initialized to the maximum value of + gradient_list. + min_grad: minimum value that the gradient_list can take, it will be + used to normalize the ``gradient_criteria`` in order to get values + that can be used in the function colormap.__call__(). If not + declared it will be initialized to the minimum value of + gradient_list. + chart: figure over with the graphs are plotted or axis over where the graphs are plotted. If None and ax is also None, the figure is initialized. - fig (figure object, optional): figure over with the graphs are + fig: figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - axes (list of axis objects, optional): axis over where the graphs are - plotted. If None, see param fig. - n_rows (int, optional): designates the number of rows of the figure + axes: axis over where the graphs + are plotted. If None, see param fig. + n_rows: designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - n_cols(int, optional): designates the number of columns of the + n_cols: designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - n_points (int or tuple, optional): Number of points to evaluate in + n_points: Number of points to evaluate in the plot. In case of surfaces a tuple of length 2 can be pased with the number of points to plot in each axis, otherwise the same number of points will be used in the two axes. By default in unidimensional plots will be used 501 points; in surfaces will be used 30 points per axis, wich makes a grid with 900 points. - domain_range (tuple or list of tuples, optional): Range where the + domain_range: Range where the function will be plotted. In objects with unidimensional domain the domain range should be a tuple with the bounds of the interval; in the case of surfaces a list with 2 tuples with the ranges for each dimension. Default uses the domain range of the functional object. - group (list of int): contains integers from [0 to number of + group: contains integers from [0 to number of labels) indicating to which group each sample belongs to. Then, the samples with the same label are plotted in the same color. If None, the default value, each sample is plotted in the color assigned by matplotlib.pyplot.rcParams['axes.prop_cycle']. - group_colors (list of colors): colors in which groups are + group_colors: colors in which groups are represented, there must be one for each group. If None, each group is shown with distict colors in the "Greys" colormap. - group_names (list of str): name of each of the groups which appear + group_names: name of each of the groups which appear in a legend, there must be one for each one. Defaults to None and the legend is not shown. Implies `legend=True`. - legend (bool): if `True`, show a legend with the groups. If + colormap: name of the colormap to be used. By default we will + use autumn. + legend: if `True`, show a legend with the groups. If `group_names` is passed, it will be used for finding the names to display in the legend. Otherwise, the values passed to `group` will be used. - **kwargs: if dim_domain is 1, keyword arguments to be passed to + kwargs: if dim_domain is 1, keyword arguments to be passed to the matplotlib.pyplot.plot function; if dim_domain is 2, keyword arguments to be passed to the matplotlib.pyplot.plot_surface function. + Attributes: + gradient_list: normalization of the values from gradient color_list + that will be used to determine the intensity of the color + each function will have. + """ - Returns: - fig (figure object): figure object in which the graphs are plotted. + def __init__( + self, + fdata: FData, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + n_points: Union[int, Tuple[int, int], None] = None, + domain_range: Optional[DomainRangeLike] = None, + group: Optional[Sequence[K]] = None, + group_colors: Optional[Indexable[K, ColorLike]] = None, + group_names: Optional[Indexable[K, str]] = None, + gradient_criteria: Optional[Sequence[float]] = None, + max_grad: Optional[float] = None, + min_grad: Optional[float] = None, + colormap: Union[Colormap, str, None] = None, + legend: bool = False, + **kwargs: Any, + ) -> None: + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + ) + self.fdata = fdata + self.gradient_criteria = gradient_criteria + if self.gradient_criteria is not None: + if len(self.gradient_criteria) != fdata.n_samples: + raise ValueError( + "The length of the gradient color", + "list should be the same as the number", + "of samples in fdata", + ) + + if min_grad is None: + self.min_grad = min(self.gradient_criteria) + else: + self.min_grad = min_grad + + if max_grad is None: + self.max_grad = max(self.gradient_criteria) + else: + self.max_grad = max_grad + + aux_list = [ + grad_color - self.min_grad + for grad_color in self.gradient_criteria + ] + + self.gradient_list: Sequence[float] = ( + [ + aux / (self.max_grad - self.min_grad) + for aux in aux_list + ] + ) + else: + self.gradient_list = None - """ + self.n_points = n_points + self.group = group + self.group_colors = group_colors + self.group_names = group_names + self.legend = legend + self.colormap = colormap - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) + if domain_range is None: + self.domain_range = self.fdata.domain_range + else: + self.domain_range = _to_domain_range(domain_range) + + if self.gradient_list is None: + sample_colors, patches = _get_color_info( + self.fdata, + self.group, + self.group_names, + self.group_colors, + self.legend, + kwargs, + ) + else: + patches = None + if self.colormap is None: + colormap = matplotlib.cm.get_cmap("autumn") + colormap = colormap.reversed() + else: + colormap = matplotlib.cm.get_cmap(self.colormap) - if domain_range is None: - domain_range = fdata.domain_range - else: - domain_range = _tuple_of_arrays(domain_range) + sample_colors = [None] * self.fdata.n_samples + for m in range(self.fdata.n_samples): + sample_colors[m] = colormap(self.gradient_list[m]) - sample_colors, patches = _get_color_info( - fdata, group, group_names, group_colors, legend, kwargs) + self.sample_colors = sample_colors + self.patches = patches - if fdata.dim_domain == 1: + @property + def dim(self) -> int: + return self.fdata.dim_domain + 1 - if n_points is None: - n_points = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH + @property + def n_subplots(self) -> int: + return self.fdata.dim_codomain - # Evaluates the object in a linspace - eval_points = np.linspace(*domain_range[0], n_points) - mat = fdata(eval_points) + @property + def n_samples(self) -> int: + return self.fdata.n_samples - color_dict = {} + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: - for i in range(fdata.dim_codomain): - for j in range(fdata.n_samples): + self.artists = np.zeros( + (self.n_samples, self.fdata.dim_codomain), + dtype=Artist, + ) - if sample_colors is not None: - color_dict["color"] = sample_colors[j] + color_dict: Mapping[str, Optional[ColorLike]] = {} - axes[i].plot(eval_points, mat[j, ..., i].T, - **color_dict, **kwargs) + if self.fdata.dim_domain == 1: - else: + if self.n_points is None: + self.n_points = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH - # Selects the number of points - if n_points is None: - npoints = 2 * (constants.N_POINTS_SURFACE_PLOT_AX,) - elif np.isscalar(npoints): - npoints = (npoints, npoints) - elif len(npoints) != 2: - raise ValueError(f"n_points should be a number or a tuple of " - f"length 2, and has length {len(npoints)}") + # Evaluates the object in a linspace + eval_points = np.linspace(*self.domain_range[0], self.n_points) + mat = self.fdata(eval_points) - # Axes where will be evaluated - x = np.linspace(*domain_range[0], npoints[0]) - y = np.linspace(*domain_range[1], npoints[1]) + for i in range(self.fdata.dim_codomain): + for j in range(self.fdata.n_samples): + + set_color_dict(self.sample_colors, j, color_dict) + + self.artists[j, i] = axes[i].plot( + eval_points, + mat[j, ..., i].T, + **color_dict, + )[0] + + else: - # Evaluation of the functional object - Z = fdata((x, y), grid=True) + # Selects the number of points + if self.n_points is None: + n_points_tuple = 2 * (constants.N_POINTS_SURFACE_PLOT_AX,) + elif isinstance(self.n_points, int): + n_points_tuple = (self.n_points, self.n_points) + elif len(self.n_points) != 2: + raise ValueError( + "n_points should be a number or a tuple of " + "length 2, and has " + "length {0}.".format(len(self.n_points)), + ) - X, Y = np.meshgrid(x, y, indexing='ij') + # Axes where will be evaluated + x = np.linspace(*self.domain_range[0], n_points_tuple[0]) + y = np.linspace(*self.domain_range[1], n_points_tuple[1]) - color_dict = {} + # Evaluation of the functional object + Z = self.fdata((x, y), grid=True) - for i in range(fdata.dim_codomain): - for j in range(fdata.n_samples): + X, Y = np.meshgrid(x, y, indexing='ij') - if sample_colors is not None: - color_dict["color"] = sample_colors[j] + for k in range(self.fdata.dim_codomain): + for h in range(self.fdata.n_samples): - axes[i].plot_surface(X, Y, Z[j, ..., i], - **color_dict, **kwargs) + set_color_dict(self.sample_colors, h, color_dict) - _set_labels(fdata, fig, axes, patches) + self.artists[h, k] = axes[k].plot_surface( + X, + Y, + Z[h, ..., k], + **color_dict, + ) - return fig + _set_labels(self.fdata, fig, axes, self.patches) -def plot_scatter(fdata, chart=None, *, grid_points=None, - fig=None, axes=None, - n_rows=None, n_cols=None, domain_range=None, - group=None, group_colors=None, group_names=None, - legend: bool = False, - **kwargs): - """Plot the FDatGrid object. +class ScatterPlot(BasePlot): + """ + Class used to scatter the FDataGrid object. Args: - chart (figure object, axe or list of axes, optional): figure over + fdata: functional data set that we want to plot. + grid_points: points to plot. + chart: figure over with the graphs are plotted or axis over where the graphs are plotted. If None and ax is also None, the figure is initialized. - grid_points (ndarray): points to plot. - fig (figure object, optional): figure over with the graphs are + fig: figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - axes (list of axis objects, optional): axis over where the graphs are - plotted. If None, see param fig. - n_rows (int, optional): designates the number of rows of the figure + axes: axis over where the graphs + are plotted. If None, see param fig. + n_rows: designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - n_cols(int, optional): designates the number of columns of the + n_cols: designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - domain_range (tuple or list of tuples, optional): Range where the + domain_range: Range where the function will be plotted. In objects with unidimensional domain the domain range should be a tuple with the bounds of the interval; in the case of surfaces a list with 2 tuples with the ranges for each dimension. Default uses the domain range of the functional object. - group (list of int): contains integers from [0 to number of + group: contains integers from [0 to number of labels) indicating to which group each sample belongs to. Then, the samples with the same label are plotted in the same color. If None, the default value, each sample is plotted in the color assigned by matplotlib.pyplot.rcParams['axes.prop_cycle']. - group_colors (list of colors): colors in which groups are + group_colors: colors in which groups are represented, there must be one for each group. If None, each group is shown with distict colors in the "Greys" colormap. - group_names (list of str): name of each of the groups which appear + group_names: name of each of the groups which appear in a legend, there must be one for each one. Defaults to None and the legend is not shown. Implies `legend=True`. - legend (bool): if `True`, show a legend with the groups. If + legend: if `True`, show a legend with the groups. If `group_names` is passed, it will be used for finding the names to display in the legend. Otherwise, the values passed to `group` will be used. - **kwargs: if dim_domain is 1, keyword arguments to be passed to + kwargs: if dim_domain is 1, keyword arguments to be passed to the matplotlib.pyplot.plot function; if dim_domain is 2, keyword arguments to be passed to the matplotlib.pyplot.plot_surface function. - - Returns: - fig (figure object): figure object in which the graphs are plotted. - """ - evaluated_points = None - - if grid_points is None: - # This can only be done for FDataGrid - grid_points = fdata.grid_points - evaluated_points = fdata.data_matrix - - if evaluated_points is None: - evaluated_points = fdata( - grid_points, grid=True) - - fig, axes = _get_figure_and_axes(chart, fig, axes) - fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) - - if domain_range is None: - domain_range = fdata.domain_range - else: - domain_range = _tuple_of_arrays(domain_range) - - sample_colors, patches = _get_color_info( - fdata, group, group_names, group_colors, legend, kwargs) - - if fdata.dim_domain == 1: - - color_dict = {} - - for i in range(fdata.dim_codomain): - for j in range(fdata.n_samples): - - if sample_colors is not None: - color_dict["color"] = sample_colors[j] + def __init__( + self, + fdata: FData, + chart: Union[Figure, Axes, None] = None, + *, + fig: Optional[Figure] = None, + axes: Optional[Axes] = None, + n_rows: Optional[int] = None, + n_cols: Optional[int] = None, + grid_points: Optional[GridPointsLike] = None, + domain_range: Union[Tuple[int, int], DomainRangeLike, None] = None, + group: Optional[Sequence[K]] = None, + group_colors: Optional[Indexable[K, ColorLike]] = None, + group_names: Optional[Indexable[K, str]] = None, + legend: bool = False, + **kwargs: Any, + ) -> None: + BasePlot.__init__( + self, + chart, + fig=fig, + axes=axes, + n_rows=n_rows, + n_cols=n_cols, + ) + self.fdata = fdata + self.grid_points = grid_points + + self.evaluated_points = None + if self.grid_points is None: + # This can only be done for FDataGrid + self.grid_points = self.fdata.grid_points + self.evaluated_points = self.fdata.data_matrix + else: + self.evaluated_points = self.fdata( + self.grid_points, grid=True, + ) + + self.domain_range = domain_range + self.group = group + self.group_colors = group_colors + self.group_names = group_names + self.legend = legend + + if self.domain_range is None: + self.domain_range = self.fdata.domain_range + else: + self.domain_range = _to_domain_range(self.domain_range) + + sample_colors, patches = _get_color_info( + self.fdata, + self.group, + self.group_names, + self.group_colors, + self.legend, + kwargs, + ) + self.sample_colors = sample_colors + self.patches = patches + + @property + def dim(self) -> int: + return self.fdata.dim_domain + 1 + + @property + def n_subplots(self) -> int: + return self.fdata.dim_codomain + + @property + def n_samples(self) -> int: + return self.fdata.n_samples + + def _plot( + self, + fig: Figure, + axes: Sequence[Axes], + ) -> None: + """ + Scatter FDataGrid object. + + Returns: + fig: figure object in which the graphs are plotted. + """ + self.artists = np.zeros( + (self.n_samples, self.fdata.dim_codomain), + dtype=Artist, + ) + + color_dict: Mapping[str, Optional[ColorLike]] = {} + + if self.fdata.dim_domain == 1: + + for i in range(self.fdata.dim_codomain): + for j in range(self.fdata.n_samples): + + set_color_dict(self.sample_colors, j, color_dict) + + self.artists[j, i] = axes[i].scatter( + self.grid_points[0], + self.evaluated_points[j, ..., i].T, + **color_dict, + picker=True, + pickradius=2, + ) - axes[i].scatter(grid_points[0], - evaluated_points[j, ..., i].T, - **color_dict, **kwargs) + else: - else: + X = self.fdata.grid_points[0] + Y = self.fdata.grid_points[1] + X, Y = np.meshgrid(X, Y) - X = fdata.grid_points[0] - Y = fdata.grid_points[1] - X, Y = np.meshgrid(X, Y) + for k in range(self.fdata.dim_codomain): + for h in range(self.fdata.n_samples): - color_dict = {} + set_color_dict(self.sample_colors, h, color_dict) - for i in range(fdata.dim_codomain): - for j in range(fdata.n_samples): + self.artists[h, k] = axes[k].scatter( + X, + Y, + self.evaluated_points[h, ..., k].T, + **color_dict, + picker=True, + pickradius=2, + ) - if sample_colors is not None: - color_dict["color"] = sample_colors[j] + _set_labels(self.fdata, fig, axes, self.patches) - axes[i].scatter(X, Y, - evaluated_points[j, ..., i].T, - **color_dict, **kwargs) - _set_labels(fdata, fig, axes, patches) +def set_color_dict( + sample_colors: Any, + ind: int, + color_dict: Mapping[str, Optional[ColorLike]], +) -> None: + """ + Auxiliary method used to update color_dict. - return fig + Sets the new color of the color_dict + thanks to sample colors and index. + """ + if sample_colors is not None: + color_dict["color"] = sample_colors[ind] diff --git a/skfda/inference/anova/_anova_oneway.py b/skfda/inference/anova/_anova_oneway.py index 41fe8f5d6..6b38d53f2 100644 --- a/skfda/inference/anova/_anova_oneway.py +++ b/skfda/inference/anova/_anova_oneway.py @@ -1,17 +1,23 @@ -from typing import List, Tuple, Union +from __future__ import annotations + +from typing import Tuple, Union, overload import numpy as np from sklearn.utils import check_random_state +from typing_extensions import Literal from ... import concatenate from ..._utils import RandomStateLike from ...datasets import make_gaussian_process from ...misc.metrics import lp_distance from ...representation import FData, FDataGrid +from ...representation._typing import ArrayLike -def v_sample_stat(fd: FData, weights: List[int], p: int = 2) -> float: +def v_sample_stat(fd: FData, weights: ArrayLike, p: int = 2) -> float: r""" + Compute sample statistic. + Calculates a statistic that measures the variability between groups of samples in a :class:`skfda.representation.FData` object. @@ -27,7 +33,7 @@ def v_sample_stat(fd: FData, weights: List[int], p: int = 2) -> float: .. math:: V_n = \sum_{i float: ValueError Examples: - >>> from skfda.inference.anova import v_sample_stat >>> from skfda.representation.grid import FDataGrid >>> import numpy as np @@ -67,11 +72,9 @@ def v_sample_stat(fd: FData, weights: List[int], p: int = 2) -> float: 0.01649448843348894 References: - [1] Antonio Cuevas, Manuel Febrero-Bande, and Ricardo Fraiman. "An - anova test for functional data". *Computational Statistics Data - Analysis*, 47:111-112, 02 2004 - """ + .. footbibliography:: + """ weights = np.asarray(weights) if not isinstance(fd, FData): raise ValueError("Argument type must inherit FData.") @@ -80,11 +83,19 @@ def v_sample_stat(fd: FData, weights: List[int], p: int = 2) -> float: t_ind = np.tril_indices(fd.n_samples, -1) coef = weights[t_ind[1]] - return np.sum(coef * lp_distance(fd[t_ind[0]], fd[t_ind[1]], p=p) ** p) + return float(np.sum( + coef * lp_distance( + fd[t_ind[0]], + fd[t_ind[1]], + p=p, + ) ** p, + )) -def v_asymptotic_stat(fd: FData, weights: List[int], p: int = 2) -> float: +def v_asymptotic_stat(fd: FData, weights: ArrayLike, p: int = 2) -> float: r""" + Compute asymptitic statistic. + Calculates a statistic that measures the variability between groups of samples in a :class:`skfda.representation.FData` object. @@ -100,7 +111,7 @@ def v_asymptotic_stat(fd: FData, weights: List[int], p: int = 2) -> float: .. math:: \sum_{i float: ValueError Examples: - >>> from skfda.inference.anova import v_asymptotic_stat >>> from skfda.representation.grid import FDataGrid >>> import numpy as np @@ -140,9 +150,8 @@ def v_asymptotic_stat(fd: FData, weights: List[int], p: int = 2) -> float: 0.0018159320335885969 References: - [1] Antonio Cuevas, Manuel Febrero-Bande, and Ricardo Fraiman. "An - anova test for functional data". *Computational Statistics Data - Analysis*, 47:111-112, 02 2004 + .. footbibliography:: + """ weights = np.asarray(weights) if not isinstance(fd, FData): @@ -156,7 +165,7 @@ def v_asymptotic_stat(fd: FData, weights: List[int], p: int = 2) -> float: coef = np.sqrt(weights[t_ind[1]] / weights[t_ind[0]]) left_fd = fd[t_ind[1]] right_fd = fd[t_ind[0]] * coef - return np.sum(lp_distance(left_fd, right_fd, p=p) ** p) + return float(np.sum(lp_distance(left_fd, right_fd, p=p) ** p)) def _anova_bootstrap( @@ -173,8 +182,9 @@ def _anova_bootstrap( for fd in fd_grouped[1:]: if not np.array_equal(fd.domain_range, fd_grouped[0].domain_range): - raise ValueError("Domain range must match for every FData in " - "fd_grouped.") + raise ValueError( + "Domain range must match for every FData in fd_grouped.", + ) start, stop = fd_grouped[0].domain_range[0] @@ -197,10 +207,17 @@ def _anova_bootstrap( # Simulating n_reps observations for each of the n_groups gaussian # processes - sim = [make_gaussian_process(n_reps, n_features=n_features, start=start, - stop=stop, cov=k_est[i], - random_state=random_state) - for i in range(n_groups)] + sim = [ + make_gaussian_process( + n_reps, + n_features=n_features, + start=start, + stop=stop, + cov=k_est[i], + random_state=random_state, + ) + for i in range(n_groups) + ] v_samples = np.empty(n_reps) for i in range(n_reps): @@ -209,6 +226,30 @@ def _anova_bootstrap( return v_samples +@overload +def oneway_anova( + *args: FData, + n_reps: int = 2000, + return_dist: Literal[False] = False, + random_state: RandomStateLike = None, + p: int = 2, + equal_var: bool = True, +) -> Tuple[float, float]: + pass + + +@overload +def oneway_anova( + *args: FData, + n_reps: int = 2000, + return_dist: Literal[True], + random_state: RandomStateLike = None, + p: int = 2, + equal_var: bool = True, +) -> Tuple[float, float, np.ndarray]: + pass + + def oneway_anova( *args: FData, n_reps: int = 2000, @@ -218,7 +259,7 @@ def oneway_anova( equal_var: bool = True, ) -> Union[Tuple[float, float], Tuple[float, float, np.ndarray]]: r""" - Performs one-way functional ANOVA. + Perform one-way functional ANOVA. This function implements an asymptotic method to test the following null hypothesis: @@ -245,24 +286,19 @@ def oneway_anova( calculated. This procedure is repeated `n_reps` times, creating a sampling distribution of the statistic. - This procedure is from Cuevas[1]. + This procedure is from Cuevas :footcite:`cuevas++_2004_anova`. Args: args: The sample measurements for each each group. - n_reps: Number of simulations for the bootstrap procedure. Defaults to 2000 (This value may change in future versions). - return_dist: Flag to indicate if the function should return a numpy.array with the sampling distribution simulated. - random_state: Random state. - p: p of the lp norm. Must be greater or equal than 1. If p='inf' or p=np.inf it is used the L infinity metric. Defaults to 2. - equal_var: If True (default), perform a One-way ANOVA assuming the same covariance operator for all the groups, else considers an independent covariance operator for each group. @@ -290,11 +326,9 @@ def oneway_anova( [ 184.0698 212.7395 195.3663] References: - [1] Antonio Cuevas, Manuel Febrero-Bande, and Ricardo Fraiman. "An - anova test for functional data". *Computational Statistics Data - Analysis*, 47:111-112, 02 2004 - """ + .. footbibliography:: + """ if len(args) < 2: raise ValueError("At least two groups must be passed as parameter.") if not all(isinstance(fd, FData) for fd in args): @@ -303,7 +337,7 @@ def oneway_anova( raise ValueError("Number of simulations must be positive.") fd_groups = args - if not all([isinstance(fd, type(fd_groups[0])) for fd in fd_groups[1:]]): + if not all(isinstance(fd, type(fd_groups[0])) for fd in fd_groups[1:]): raise TypeError('Found mixed FData types in arguments.') for fd in fd_groups[1:]: @@ -314,14 +348,16 @@ def oneway_anova( # Creating list with all the sample points list_sample = [fd.grid_points[0].tolist() for fd in fd_groups] # Checking that the all the entries in the list are the same - if not list_sample.count(list_sample[0]) == len(list_sample): - raise ValueError("All FDataGrid passed must have the same sample " - "points.") + if list_sample.count(list_sample[0]) != len(list_sample): + raise ValueError( + "All FDataGrid passed must have the same grid points.", + ) else: # If type is FDataBasis, check same basis list_basis = [fd.basis for fd in fd_groups] - if not list_basis.count(list_basis[0]) == len(list_basis): - raise NotImplementedError("Not implemented for FDataBasis with " - "different basis.") + if list_basis.count(list_basis[0]) != len(list_basis): + raise NotImplementedError( + "Not implemented for FDataBasis with different basis.", + ) # FData where each sample is the mean of each group fd_means = concatenate([fd.mean() for fd in fd_groups]) @@ -330,9 +366,13 @@ def oneway_anova( vn = v_sample_stat(fd_means, [fd.n_samples for fd in fd_groups], p=p) # Computing sampling distribution - simulation = _anova_bootstrap(fd_groups, n_reps, - random_state=random_state, p=p, - equal_var=equal_var) + simulation = _anova_bootstrap( + fd_groups, + n_reps, + random_state=random_state, + p=p, + equal_var=equal_var, + ) p_value = np.sum(simulation > vn) / len(simulation) diff --git a/skfda/inference/hotelling/__init__.py b/skfda/inference/hotelling/__init__.py index 6498f54bc..d637b2634 100644 --- a/skfda/inference/hotelling/__init__.py +++ b/skfda/inference/hotelling/__init__.py @@ -1,2 +1,4 @@ -from . import hotelling -from .hotelling import hotelling_t2, hotelling_test_ind +""" +Hotelling statistic and test. +""" +from ._hotelling import hotelling_t2, hotelling_test_ind diff --git a/skfda/inference/hotelling/hotelling.py b/skfda/inference/hotelling/_hotelling.py similarity index 56% rename from skfda/inference/hotelling/hotelling.py rename to skfda/inference/hotelling/_hotelling.py index f5fde264a..854617790 100644 --- a/skfda/inference/hotelling/hotelling.py +++ b/skfda/inference/hotelling/_hotelling.py @@ -1,69 +1,78 @@ -from skfda.representation import FDataBasis, FData -import numpy as np import itertools -import scipy +from typing import Optional, Tuple, Union, overload + +import numpy as np from sklearn.utils import check_random_state +from typing_extensions import Literal + +import scipy.special + +from ..._utils import RandomStateLike +from ...representation import FData, FDataBasis -def hotelling_t2(fd1, fd2): +def hotelling_t2( + fd1: FData, + fd2: FData, +) -> float: r""" - Calculates Hotelling's :math:`T^2` over two samples in - :class:`skfda.representation.FData` objects with sizes :math:`n_1` - and :math:`n_2`. - - .. math:: - T^2 = n(\mathbf{m}_1 - \mathbf{m}_2)^\top \mathbf{W}^{1/2}( - \mathbf{W}^{1/2}\mathbf{K_{\operatorname{pooled}}} \mathbf{W}^{ - 1/2})^+ - \mathbf{W}^{1/2} (\mathbf{m}_1 - \mathbf{m}_2), - - where :math:`(\cdot)^{+}` indicates the Moore-Penrose pseudo-inverse - operator, :math:`n=n_1+n_2`, `W` is Gram matrix (identity in case of - discretized data), :math:`\mathbf{m}_1, \mathbf{m}_2` are the - means of each ample and :math:`\mathbf{K}_{\operatorname{pooled}}` - matrix is defined as - - .. math:: - \mathbf{K}_{\operatorname{pooled}} := - \cfrac{n_1 - 1}{n_1 + n_2 - 2} \mathbf{K}_{n_1} + - \cfrac{n_2 - 1}{n_1 + n_2 - 2} \mathbf{K}_{n_2}, - - where :math:`\mathbf{K}_{n_1}`, :math:`\mathbf{K}_{n_2}` are the sample - covariance matrices, computed with the basis coefficients or using - the discrete representation, depending on the input. - - This statistic is defined in Pini, Stamm and Vantini[1]. - - Args: - fd1 (FData): Object with the first sample. - fd2 (FData): Object containing second sample. - - Returns: - The value of the statistic. - - Raises: - TypeError. - - Examples: - - >>> from skfda.inference.hotelling import hotelling_t2 - >>> from skfda.representation import FDataGrid, basis - - >>> fd1 = FDataGrid([[1, 1, 1], [3, 3, 3]]) - >>> fd2 = FDataGrid([[3, 3, 3], [5, 5, 5]]) - >>> '%.2f' % hotelling_t2(fd1, fd2) - '2.00' - >>> fd1 = fd1.to_basis(basis.Fourier(n_basis=3)) - >>> fd2 = fd2.to_basis(basis.Fourier(n_basis=3)) - >>> '%.2f' % hotelling_t2(fd1, fd2) - '2.00' - - References: - [1] A. Pini, A. Stamm and S. Vantini, "Hotelling's t2 in - separable hilbert spaces", *Jounal of Multivariate Analysis*, - 167 (2018), pp.284-305. - - """ + Compute Hotelling's :math:`T^2` statistic. + + Calculates Hotelling's :math:`T^2` over two samples in + :class:`skfda.representation.FData` objects with sizes :math:`n_1` + and :math:`n_2`. + + .. math:: + T^2 = n(\mathbf{m}_1 - \mathbf{m}_2)^\top \mathbf{W}^{1/2}( + \mathbf{W}^{1/2}\mathbf{K_{\operatorname{pooled}}} \mathbf{W}^{ + 1/2})^+ + \mathbf{W}^{1/2} (\mathbf{m}_1 - \mathbf{m}_2), + + where :math:`(\cdot)^{+}` indicates the Moore-Penrose pseudo-inverse + operator, :math:`n=n_1+n_2`, `W` is Gram matrix (identity in case of + discretized data), :math:`\mathbf{m}_1, \mathbf{m}_2` are the + means of each ample and :math:`\mathbf{K}_{\operatorname{pooled}}` + matrix is defined as + + .. math:: + \mathbf{K}_{\operatorname{pooled}} := + \cfrac{n_1 - 1}{n_1 + n_2 - 2} \mathbf{K}_{n_1} + + \cfrac{n_2 - 1}{n_1 + n_2 - 2} \mathbf{K}_{n_2}, + + where :math:`\mathbf{K}_{n_1}`, :math:`\mathbf{K}_{n_2}` are the sample + covariance matrices, computed with the basis coefficients or using + the discrete representation, depending on the input. + + This statistic is defined in Pini, Stamm and Vantini + :footcite:`pini+stamm+vantini_2018_hotellings`. + + Args: + fd1: Object with the first sample. + fd2: Object containing second sample. + + Returns: + The value of the statistic. + + Raises: + TypeError. + + Examples: + >>> from skfda.inference.hotelling import hotelling_t2 + >>> from skfda.representation import FDataGrid, basis + + >>> fd1 = FDataGrid([[1, 1, 1], [3, 3, 3]]) + >>> fd2 = FDataGrid([[3, 3, 3], [5, 5, 5]]) + >>> '%.2f' % hotelling_t2(fd1, fd2) + '2.00' + >>> fd1 = fd1.to_basis(basis.Fourier(n_basis=3)) + >>> fd2 = fd2.to_basis(basis.Fourier(n_basis=3)) + >>> '%.2f' % hotelling_t2(fd1, fd2) + '2.00' + + References: + .. footbibliography:: + + """ if not isinstance(fd1, FData): raise TypeError("Argument type must inherit FData.") @@ -76,8 +85,9 @@ def hotelling_t2(fd1, fd2): if isinstance(fd1, FDataBasis): if fd1.basis != fd2.basis: - raise ValueError("Both FDataBasis objects must share the same " - "basis.") + raise ValueError( + "Both FDataBasis objects must share the same basis.", + ) # When working on basis representation we use the coefficients m = m.coefficients[0] k1 = np.cov(fd1.coefficients, rowvar=False) @@ -95,6 +105,8 @@ def hotelling_t2(fd1, fd2): k_pool = ((n1 - 1) * k1 + (n2 - 1) * k2) / (n - 2) # Combination of covs if isinstance(fd1, FDataBasis): + assert weights is not None + # Product of pooled covariance with the weights and Moore-Penrose inv. k_inv = np.linalg.pinv(np.linalg.multi_dot([weights, k_pool, weights])) k_inv = weights.dot(k_inv).dot(weights) @@ -102,12 +114,44 @@ def hotelling_t2(fd1, fd2): # If data is discrete no weights are needed k_inv = np.linalg.pinv(k_pool) - return n1 * n2 / n * m.T.dot(k_inv).dot(m)[0][0] - + return float(n1 * n2 / n * m.T.dot(k_inv).dot(m)[0][0]) + + +@overload +def hotelling_test_ind( + fd1: FData, + fd2: FData, + *, + n_reps: Optional[int] = None, + random_state: RandomStateLike = None, + return_dist: Literal[False] = False, +) -> Tuple[float, float]: + pass + + +@overload +def hotelling_test_ind( + fd1: FData, + fd2: FData, + *, + n_reps: Optional[int] = None, + random_state: RandomStateLike = None, + return_dist: Literal[True], +) -> Tuple[float, float, np.ndarray]: + pass + + +def hotelling_test_ind( + fd1: FData, + fd2: FData, + *, + n_reps: Optional[int] = None, + random_state: RandomStateLike = None, + return_dist: bool = False, +) -> Union[Tuple[float, float], Tuple[float, float, np.ndarray]]: + """ + Compute Hotelling :math:`T^2`-test. -def hotelling_test_ind(fd1, fd2, *, n_reps=None, random_state=None, - return_dist=False): - r""" Calculate the :math:`T^2`-test for the means of two independent samples of functional data. @@ -120,29 +164,24 @@ def hotelling_test_ind(fd1, fd2, *, n_reps=None, random_state=None, number of repetitions of the algorithm is provided then the permutations tested are generated randomly. - This procedure is from Pini, Stamm and Vantinni[1]. + This procedure is from Pini, Stamm and Vantinni + :footcite:`pini+stamm+vantini_2018_hotellings`. Args: - fd1,fd2 (FData): Samples of data. The FData objects must have the same + fd1: First sample of data. + fd2: Second sample of data. The data objects must have the same type. - - n_reps (int, optional): Maximum number of repetitions to compute + n_reps: Maximum number of repetitions to compute p-value. Default value is None. - - random_state (optional): Random state. - - return_dist (bool, optional): Flag to indicate if the function should + random_state: Random state. + return_dist: Flag to indicate if the function should return a numpy.array with the values of the statistic computed over each permutation. - Returns: Value of the sample statistic, one tailed p-value and a collection of statistic values from permutations of the sample. - Return type: - (float, float, numpy.array) - Raises: TypeError: In case of bad arguments. @@ -163,9 +202,8 @@ def hotelling_test_ind(fd1, fd2, *, n_reps=None, random_state=None, [ 2. 2. 0. 0. 2. 2.] References: - [1] A. Pini, A. Stamm and S. Vantini, "Hotelling's t2 in - separable hilbert spaces", *Jounal of Multivariate Analysis*, - 167 (2018), pp.284-305. + .. footbibliography:: + """ if not isinstance(fd1, FData): raise TypeError("Argument type must inherit FData.") diff --git a/skfda/misc/__init__.py b/skfda/misc/__init__.py index f3ef87ad1..83431f624 100644 --- a/skfda/misc/__init__.py +++ b/skfda/misc/__init__.py @@ -1,5 +1,11 @@ -from . import covariances, kernels, metrics -from . import operators -from . import regularization -from ._math import (log, log2, log10, exp, sqrt, cumsum, - inner_product, inner_product_matrix) +from . import covariances, kernels, lstsq, metrics, operators, regularization +from ._math import ( + cumsum, + exp, + inner_product, + inner_product_matrix, + log, + log2, + log10, + sqrt, +) diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index 8af2af8c2..252b4f79e 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -4,131 +4,196 @@ package. FDataBasis and FDataGrid. """ +import warnings from builtins import isinstance -from typing import Union +from typing import Any, Callable, Optional, TypeVar, Union, cast import multimethod -import scipy.integrate - import numpy as np -from .._utils import _same_domain, nquad_vec, _pairwise_commutative -from ..representation import FDataGrid, FDataBasis -from ..representation.basis import Basis +import scipy.integrate +from .._utils import _same_domain, nquad_vec +from ..representation import FData, FDataBasis, FDataGrid +from ..representation._typing import DomainRange +from ..representation.basis import Basis -__author__ = "Miguel Carbajo Berrocal" -__license__ = "GPL3" -__version__ = "" -__maintainer__ = "" -__email__ = "" -__status__ = "Development" +Vector = TypeVar( + "Vector", + bound=Union[np.ndarray, Basis, Callable[[np.ndarray], np.ndarray]], +) -def sqrt(fdatagrid): +def sqrt(fdatagrid: FDataGrid) -> FDataGrid: """Perform a element wise square root operation. + .. deprecated:: 0.6 + Use :func:`numpy.sqrt` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements the square root + fdatagrid: Object to whose elements the square root operation is going to be applied. Returns: - FDataGrid: Object whose elements are the square roots of the original. + FDataGrid object whose elements are the square roots of the original. """ - return fdatagrid.copy(data_matrix=np.sqrt(fdatagrid.data_matrix)) + warnings.warn( + "Function sqrt is deprecated. Use numpy.sqrt with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + + return cast(FDataGrid, np.sqrt(fdatagrid)) -def absolute(fdatagrid): +def absolute(fdatagrid: FDataGrid) -> FDataGrid: """Get the absolute value of all elements in the FDataGrid object. + .. deprecated:: 0.6 + Use :func:`numpy.absolute` function instead. + Args: - fdatagrid (FDataGrid): Object from whose elements the absolute value + fdatagrid: Object from whose elements the absolute value is going to be retrieved. Returns: - FDataGrid: Object whose elements are the absolute values of the + FDataGrid object whose elements are the absolute values of the original. """ - return fdatagrid.copy(data_matrix=np.absolute(fdatagrid.data_matrix)) + warnings.warn( + "Function absolute is deprecated. Use numpy.absolute with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + + return cast(FDataGrid, np.absolute(fdatagrid)) -def round(fdatagrid, decimals=0): +def round( # noqa: WPS125 + fdatagrid: FDataGrid, + decimals: int = 0, +) -> FDataGrid: """Round all elements of the object. + .. deprecated:: 0.6 + Use :func:`numpy.round` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements are going to be + fdatagrid: Object to whose elements are going to be rounded. - decimals (int, optional): Number of decimals wanted. Defaults to 0. + decimals: Number of decimals wanted. Defaults to 0. Returns: - FDataGrid: Object whose elements are rounded. + FDataGrid object whose elements are rounded. """ - return fdatagrid.round(decimals) + warnings.warn( + "Function round is deprecated. Use numpy.round with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + + return cast(FDataGrid, np.round(fdatagrid, decimals)) -def exp(fdatagrid): +def exp(fdatagrid: FDataGrid) -> FDataGrid: """Perform a element wise exponential operation. + .. deprecated:: 0.6 + Use :func:`numpy.exp` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements the exponential + fdatagrid: Object to whose elements the exponential operation is going to be applied. Returns: - FDataGrid: Object whose elements are the result of exponentiating + FDataGrid object whose elements are the result of exponentiating the elements of the original. """ - return fdatagrid.copy(data_matrix=np.exp(fdatagrid.data_matrix)) + warnings.warn( + "Function exp is deprecated. Use numpy.exp with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + return cast(FDataGrid, np.exp(fdatagrid)) -def log(fdatagrid): + +def log(fdatagrid: FDataGrid) -> FDataGrid: """Perform a element wise logarithm operation. + .. deprecated:: 0.6 + Use :func:`numpy.log` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements the logarithm + fdatagrid: Object to whose elements the logarithm operation is going to be applied. Returns: - FDataGrid: Object whose elements are the logarithm of the original. + FDataGrid object whose elements are the logarithm of the original. """ - return fdatagrid.copy(data_matrix=np.log(fdatagrid.data_matrix)) + warnings.warn( + "Function log is deprecated. Use numpy.log with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + return cast(FDataGrid, np.log(fdatagrid)) -def log10(fdatagrid): + +def log10(fdatagrid: FDataGrid) -> FDataGrid: """Perform an element wise base 10 logarithm operation. + .. deprecated:: 0.6 + Use :func:`numpy.log10` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements the base 10 logarithm + fdatagrid: Object to whose elements the base 10 logarithm operation is going to be applied. Returns: - FDataGrid: Object whose elements are the base 10 logarithm of the + FDataGrid object whose elements are the base 10 logarithm of the original. """ - return fdatagrid.copy(data_matrix=np.log10(fdatagrid.data_matrix)) + warnings.warn( + "Function log10 is deprecated. Use numpy.log10 with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + return cast(FDataGrid, np.log10(fdatagrid)) -def log2(fdatagrid): + +def log2(fdatagrid: FDataGrid) -> FDataGrid: """Perform an element wise binary logarithm operation. + .. deprecated:: 0.6 + Use :func:`numpy.log2` function instead. + Args: - fdatagrid (FDataGrid): Object to whose elements the binary logarithm + fdatagrid: Object to whose elements the binary logarithm operation is going to be applied. Returns: - FDataGrid: Object whose elements are the binary logarithm of the + FDataGrid object whose elements are the binary logarithm of the original. """ - return fdatagrid.copy(data_matrix=np.log2(fdatagrid.data_matrix)) + warnings.warn( + "Function log2 is deprecated. Use numpy.log2 with a FDataGrid " + "parameter instead.", + DeprecationWarning, + ) + return cast(FDataGrid, np.log2(fdatagrid)) -def cumsum(fdatagrid): + +def cumsum(fdatagrid: FDataGrid) -> FDataGrid: """Return the cumulative sum of the samples. Args: @@ -139,12 +204,19 @@ def cumsum(fdatagrid): FDataGrid: Object with the sample wise cumulative sum. """ - return fdatagrid.copy(data_matrix=np.cumsum(fdatagrid.data_matrix, - axis=0)) + return fdatagrid.copy( + data_matrix=np.cumsum(fdatagrid.data_matrix, axis=0), + ) @multimethod.multidispatch -def inner_product(arg1, arg2, *, matrix=False, **kwargs): +def inner_product( + arg1: Vector, + arg2: Vector, + *, + _matrix: bool = False, + _domain_range: Optional[DomainRange] = None, +) -> np.ndarray: r"""Return the usual (:math:`L_2`) inner product. Calculates the inner product between matching samples in two @@ -166,17 +238,14 @@ def inner_product(arg1, arg2, *, matrix=False, **kwargs): contain only one sample (and will be broadcasted). Args: - arg1: First sample. arg2: Second sample. Returns: - numpy.darray: Vector with the inner products of each pair of samples. Examples: - This function can compute the multivariate inner product. >>> import numpy as np @@ -246,19 +315,32 @@ def inner_product(arg1, arg2, *, matrix=False, **kwargs): array([ 0.5 , 0.25]) """ + if callable(arg1) and callable(arg2): + return _inner_product_integrate( + arg1, + arg2, + _matrix=_matrix, + _domain_range=_domain_range, + ) - if callable(arg1): - return _inner_product_integrate(arg1, arg2, matrix=matrix) - else: - return (np.einsum('n...,m...->nm...', arg1, arg2).sum(axis=-1) - if matrix else (arg1 * arg2).sum(axis=-1)) + return ( + np.einsum('n...,m...->nm...', arg1, arg2).sum(axis=-1) + if _matrix else (arg1 * arg2).sum(axis=-1) + ) @inner_product.register -def inner_product_fdatagrid(arg1: FDataGrid, arg2: FDataGrid, *, matrix=False): - - if not np.array_equal(arg1.grid_points, - arg2.grid_points): +def _inner_product_fdatagrid( + arg1: FDataGrid, + arg2: FDataGrid, + *, + _matrix: bool = False, +) -> np.ndarray: + + if not np.array_equal( + arg1.grid_points, + arg2.grid_points, + ): raise ValueError("Sample points for both objects must be equal") d1 = arg1.data_matrix @@ -266,7 +348,7 @@ def inner_product_fdatagrid(arg1: FDataGrid, arg2: FDataGrid, *, matrix=False): einsum_broadcast_list = (np.arange(d1.ndim - 1) + 2).tolist() - if matrix: + if _matrix: d1 = np.copy(d1) @@ -277,31 +359,38 @@ def inner_product_fdatagrid(arg1: FDataGrid, arg2: FDataGrid, *, matrix=False): index = (slice(None),) + (np.newaxis,) * (i + 1) d1 *= weights[index] - return np.einsum(d1, [0] + einsum_broadcast_list, - d2, [1] + einsum_broadcast_list, - [0, 1]) + return np.einsum( + d1, + [0] + einsum_broadcast_list, + d2, + [1] + einsum_broadcast_list, + [0, 1], + ) - else: - integrand = d1 * d2 + integrand = d1 * d2 - for s in arg1.grid_points[::-1]: - integrand = scipy.integrate.simps(integrand, - x=s, - axis=-2) + for g in arg1.grid_points[::-1]: + integrand = scipy.integrate.simps( + integrand, + x=g, + axis=-2, + ) - return np.sum(integrand, axis=-1) + return np.sum(integrand, axis=-1) @inner_product.register(FDataBasis, FDataBasis) @inner_product.register(FDataBasis, Basis) @inner_product.register(Basis, FDataBasis) @inner_product.register(Basis, Basis) -def inner_product_fdatabasis(arg1: Union[FDataBasis, Basis], - arg2: Union[FDataBasis, Basis], - *, - matrix=False, - inner_product_matrix=None, - force_numerical=False): +def _inner_product_fdatabasis( + arg1: Union[FDataBasis, Basis], + arg2: Union[FDataBasis, Basis], + *, + _matrix: bool = False, + inner_product_matrix: Optional[np.ndarray] = None, + force_numerical: bool = False, +) -> np.ndarray: if not _same_domain(arg1, arg2): raise ValueError("Both Objects should have the same domain_range") @@ -325,12 +414,15 @@ def inner_product_fdatabasis(arg1: Union[FDataBasis, Basis], # The number of operations is less using the matrix n_ops_best_with_matrix = max( - arg1.n_samples, arg2.n_samples) > arg1.n_basis * arg2.n_basis + arg1.n_samples, + arg2.n_samples, + ) > arg1.n_basis * arg2.n_basis if not force_numerical and ( - inner_product_matrix is not None - or same_basis - or n_ops_best_with_matrix): + inner_product_matrix is not None + or same_basis + or n_ops_best_with_matrix + ): if inner_product_matrix is None: inner_product_matrix = arg1.basis.inner_product_matrix(arg2.basis) @@ -338,60 +430,95 @@ def inner_product_fdatabasis(arg1: Union[FDataBasis, Basis], coef1 = arg1.coefficients coef2 = arg2.coefficients - if matrix: - return np.einsum('nb,bc,mc->nm', - coef1, inner_product_matrix, coef2) - else: - return (coef1 @ - inner_product_matrix * - coef2).sum(axis=-1) - + if _matrix: + return np.einsum( + 'nb,bc,mc->nm', + coef1, + inner_product_matrix, + coef2, + ) + + return ( + coef1 + @ inner_product_matrix + * coef2 + ).sum(axis=-1) + + return _inner_product_integrate(arg1, arg2, _matrix=_matrix) + + +def _inner_product_integrate( + arg1: Callable[[np.ndarray], np.ndarray], + arg2: Callable[[np.ndarray], np.ndarray], + *, + _matrix: bool = False, + _domain_range: Optional[DomainRange] = None, +) -> np.ndarray: + + domain_range: DomainRange + + if isinstance(arg1, FData) and isinstance(arg2, FData): + if not np.array_equal( + arg1.domain_range, + arg2.domain_range, + ): + raise ValueError("Domain range for both objects must be equal") + + domain_range = arg1.domain_range + len_arg1 = len(arg1) + len_arg2 = len(arg2) else: - return _inner_product_integrate(arg1, arg2, matrix=matrix) - - -def _inner_product_integrate(arg1, arg2, *, matrix=False): - - if not np.array_equal(arg1.domain_range, - arg2.domain_range): - raise ValueError("Domain range for both objects must be equal") - - def integrand(*args): - f1 = arg1([*args])[:, 0, :] - f2 = arg2([*args])[:, 0, :] - - if matrix: + # If the arguments are callables, we need to pass the domain range + # explicitly. This is used internally for computing the gramian + # matrix of operators. + assert _domain_range is not None + domain_range = _domain_range + left_domain = np.array(domain_range)[:, 0] + len_arg1 = len(arg1(left_domain)) + len_arg2 = len(arg2(left_domain)) + + def integrand(*args: np.ndarray) -> np.ndarray: # noqa: WPS430 + f1 = arg1(args)[:, 0, :] + f2 = arg2(args)[:, 0, :] + + if _matrix: ret = np.einsum('n...,m...->nm...', f1, f2) - ret = ret.reshape((-1,) + ret.shape[2:]) - return ret - else: - return f1 * f2 + return ret.reshape((-1,) + ret.shape[2:]) + + return f1 * f2 integral = nquad_vec( integrand, - arg1.domain_range) + domain_range, + ) summation = np.sum(integral, axis=-1) - if matrix: - summation = summation.reshape((len(arg1), len(arg2))) + if _matrix: + summation = summation.reshape((len_arg1, len_arg2)) return summation -def inner_product_matrix(arg1, arg2=None, **kwargs): +def inner_product_matrix( + arg1: Vector, + arg2: Optional[Vector] = None, + **kwargs: Any, +) -> np.ndarray: """ - Returns the inner product matrix between is arguments. + Return the inner product matrix between is arguments. If arg2 is ``None`` returns the Gram matrix. Args: - arg1: First sample. arg2: Second sample. + kwargs: Keyword arguments for inner product. - """ + Returns: + Inner product matrix between samples. + """ if isinstance(arg1, Basis): arg1 = arg1.to_basis() if isinstance(arg2, Basis): @@ -400,4 +527,4 @@ def inner_product_matrix(arg1, arg2=None, **kwargs): if arg2 is None: arg2 = arg1 - return inner_product(arg1, arg2, matrix=True, **kwargs) + return inner_product(arg1, arg2, _matrix=True, **kwargs) diff --git a/skfda/misc/lstsq.py b/skfda/misc/lstsq.py new file mode 100644 index 000000000..9d96cb03d --- /dev/null +++ b/skfda/misc/lstsq.py @@ -0,0 +1,96 @@ +"""Methods to solve least squares problems.""" +from __future__ import annotations + +from typing import Callable, Optional, Union + +import numpy as np +from typing_extensions import Final, Literal + +import scipy.linalg + +LstsqMethodCallable = Callable[[np.ndarray, np.ndarray], np.ndarray] +LstsqMethodName = Literal["cholesky", "qr", "svd"] +LstsqMethod = Union[LstsqMethodCallable, LstsqMethodName] + + +def lstsq_cholesky( + coefs: np.ndarray, + result: np.ndarray, +) -> np.ndarray: + """Solve OLS problem using a Cholesky decomposition.""" + left = coefs.T @ coefs + right = coefs.T @ result + return scipy.linalg.solve(left, right, assume_a="pos") + + +def lstsq_qr( + coefs: np.ndarray, + result: np.ndarray, +) -> np.ndarray: + """Solve OLS problem using a QR decomposition.""" + return scipy.linalg.lstsq(coefs, result, lapack_driver="gelsy")[0] + + +def lstsq_svd( + coefs: np.ndarray, + result: np.ndarray, +) -> np.ndarray: + """Solve OLS problem using a SVD decomposition.""" + return scipy.linalg.lstsq(coefs, result, lapack_driver="gelsd")[0] + + +method_dict: Final = { + "cholesky": lstsq_cholesky, + "qr": lstsq_qr, + "svd": lstsq_svd, +} + + +def _get_lstsq_method( + method: LstsqMethod, +) -> LstsqMethodCallable: + """Convert method string to method if necessary.""" + return method if callable(method) else method_dict[method] + + +def solve_regularized_weighted_lstsq( + coefs: np.ndarray, + result: np.ndarray, + *, + weights: Optional[np.ndarray] = None, + penalty_matrix: Optional[np.ndarray] = None, + lstsq_method: LstsqMethod = lstsq_svd, +) -> np.ndarray: + """ + Solve a regularized and weighted least squares problem. + + If the penalty matrix is not ``None`` and nonzero, there + is a closed solution. Otherwise the problem can be reduced + to a least squares problem. + + """ + lstsq_method = _get_lstsq_method(lstsq_method) + + if lstsq_method is not lstsq_cholesky and ( + penalty_matrix is None + ): + # Weighted least squares case + if weights is not None: + weights_chol = scipy.linalg.cholesky(weights) + coefs = weights_chol @ coefs + result = weights_chol @ result + + return lstsq_method(coefs, result) + + # Cholesky case (always used for the regularized case) + if weights is None: + left = coefs.T @ coefs + right = coefs.T @ result + else: + left = coefs.T @ weights @ coefs + right = coefs.T @ weights @ result + + if penalty_matrix is not None: + left += penalty_matrix + + return scipy.linalg.solve(left, right, assume_a="pos") diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py deleted file mode 100644 index 2fc60799c..000000000 --- a/skfda/misc/metrics.py +++ /dev/null @@ -1,657 +0,0 @@ -from builtins import isinstance - -import scipy.integrate - -import numpy as np - -from .._utils import _pairwise_commutative -from ..preprocessing.registration import normalize_warping, ElasticRegistration -from ..preprocessing.registration._warping import _normalize_scale -from ..preprocessing.registration.elastic import SRSF -from ..representation import FData, FDataGrid, FDataBasis - - -def _check_compatible(fdata1, fdata2): - - if isinstance(fdata1, FData) and isinstance(fdata2, FData): - if (fdata2.dim_codomain != fdata1.dim_codomain or - fdata2.dim_domain != fdata1.dim_domain): - raise ValueError("Objects should have the same dimensions") - - if not np.array_equal(fdata1.domain_range, fdata2.domain_range): - raise ValueError("Domain ranges for both objects must be equal") - - -def _cast_to_grid(fdata1, fdata2, eval_points=None, _check=True, **kwargs): - """Convert fdata1 and fdata2 to FDatagrid. - - Checks if the fdatas passed as argument are unidimensional and compatible - and converts them to FDatagrid to compute their distances. - - Args: - fdata1: (:obj:`FData`): First functional object. - fdata2: (:obj:`FData`): Second functional object. - - Returns: - tuple: Tuple with two :obj:`FDataGrid` with the same grid points. - """ - # Dont perform any check - if not _check: - return fdata1, fdata2 - - _check_compatible(fdata1, fdata2) - - # Case new evaluation points specified - if eval_points is not None: - fdata1 = fdata1.to_grid(eval_points) - fdata2 = fdata2.to_grid(eval_points) - - elif not isinstance(fdata1, FDataGrid) and isinstance(fdata2, FDataGrid): - fdata1 = fdata1.to_grid(fdata2.grid_points[0]) - - elif not isinstance(fdata2, FDataGrid) and isinstance(fdata1, FDataGrid): - fdata2 = fdata2.to_grid(fdata1.grid_points[0]) - - elif (not isinstance(fdata1, FDataGrid) and - not isinstance(fdata2, FDataGrid)): - domain = fdata1.domain_range[0] - grid_points = np.linspace(*domain) - fdata1 = fdata1.to_grid(grid_points) - fdata2 = fdata2.to_grid(grid_points) - - elif not np.array_equal(fdata1.grid_points, - fdata2.grid_points): - raise ValueError("Grid points for both objects must be equal or" - "a new list evaluation points must be specified") - - return fdata1, fdata2 - - -def distance_from_norm(norm, **kwargs): - r"""Return the distance induced by a norm. - - Given a norm :math:`\| \cdot \|: X \rightarrow \mathbb{R}`, - returns the distance :math:`d: X \times X \rightarrow \mathbb{R}` induced - by the norm: - - .. math:: - d(f,g) = \|f - g\| - - Args: - norm (:obj:`Function`): Norm function `norm(fdata, **kwargs)`. - **kwargs (dict, optional): Named parameters to be passed to the norm - function. - - Returns: - :obj:`Function`: Distance function `norm_distance(fdata1, fdata2)`. - - Examples: - Computes the :math:`\mathbb{L}^2` distance between an object containing - functional data corresponding to the function :math:`y(x) = x` defined - over the interval [0, 1] and another one containing data of the - function :math:`y(x) = x/2`. - - Firstly we create the functional data. - - >>> x = np.linspace(0, 1, 1001) - >>> fd = FDataGrid([x], x) - >>> fd2 = FDataGrid([x/2], x) - - To construct the :math:`\mathbb{L}^2` distance it is used the - :math:`\mathbb{L}^2` norm wich it is used to compute the distance. - - >>> l2_distance = distance_from_norm(lp_norm, p=2) - >>> d = l2_distance(fd, fd2) - >>> float('%.3f'% d) - 0.289 - - """ - def norm_distance(fdata1, fdata2): - # Substract operation checks if objects are compatible - return norm(fdata1 - fdata2, **kwargs) - - norm_distance.__name__ = f"{norm.__name__}_distance" - - return norm_distance - - -def pairwise_distance(distance, **kwargs): - r"""Return a pairwise distance function for FData objects. - - Given a distance it returns the corresponding pairwise distance function. - - The returned pairwise distance function calculates the distance between - all possible pairs consisting of one observation of the first FDataGrid - object and one of the second one. - - The matrix returned by the pairwise distance is a matrix with as many rows - as observations in the first object and as many columns as observations in - the second one. Each element (i, j) of the matrix is the distance between - the ith observation of the first object and the jth observation of the - second one. - - Args: - distance (:obj:`Function`): Distance functions between two functional - objects `distance(fdata1, fdata2, **kwargs)`. - **kwargs (:obj:`dict`, optional): parameters dictionary to be passed - to the distance function. - - Returns: - :obj:`Function`: Pairwise distance function, wich accepts two - functional data objects and returns the pairwise distance matrix. - """ - def pairwise(fdata1, fdata2=None): - - return _pairwise_commutative(distance, fdata1, fdata2) - - pairwise.__name__ = f"pairwise_{distance.__name__}" - - return pairwise - - -def lp_norm(fdata, p=2, p2=None): - r"""Calculate the norm of all the observations in a FDataGrid object. - - For each observation f the Lp norm is defined as: - - .. math:: - \| f \| = \left( \int_D \| f \|^p dx \right)^{ - \frac{1}{p}} - - Where D is the :term:`domain` over which the functions are defined. - - The integral is approximated using Simpson's rule. - - In general, if f is a multivariate function :math:`(f_1, ..., f_d)`, and - :math:`D \subset \mathbb{R}^n`, it is applied the following generalization - of the Lp norm. - - .. math:: - \| f \| = \left( \int_D \| f \|_{*}^p dx \right)^{ - \frac{1}{p}} - - Where :math:`\| \cdot \|_*` denotes a vectorial norm. See - :func:`vectorial_norm` to more information. - - For example, if :math:`f: \mathbb{R}^2 \rightarrow \mathbb{R}^2`, and - :math:`\| \cdot \|_*` is the euclidean norm - :math:`\| (x,y) \|_* = \sqrt{x^2 + y^2}`, the lp norm applied is - - .. math:: - \| f \| = \left( \int \int_D \left ( \sqrt{ \| f_1(x,y) - \|^2 + \| f_2(x,y) \|^2 } \right )^p dxdy \right)^{ - \frac{1}{p}} - - - Args: - fdata (FData): FData object. - p (int, optional): p of the lp norm. Must be greater or equal - than 1. If p='inf' or p=np.inf it is used the L infinity metric. - Defaults to 2. - p2 (int, optional): p index of the vectorial norm applied in case of - multivariate objects. Defaults to 2. - - Returns: - numpy.darray: Matrix with as many rows as observations in the first - object and as many columns as observations in the second one. Each - element (i, j) of the matrix is the inner product of the ith - observation of the first object and the jth observation of the second - one. - - Examples: - Calculates the norm of a FDataGrid containing the functions y = 1 - and y = x defined in the interval [0,1]. - - - >>> x = np.linspace(0,1,1001) - >>> fd = FDataGrid([np.ones(len(x)), x] ,x) - >>> lp_norm(fd).round(2) - array([ 1. , 0.58]) - - The lp norm is only defined if p >= 1. - - >>> lp_norm(fd, p = 0.5) - Traceback (most recent call last): - .... - ValueError: p must be equal or greater than 1. - - """ - from ..misc import inner_product - - if p2 is None: - p2 = p - - # Special case, the inner product is heavily optimized - if p == p2 == 2: - return np.sqrt(inner_product(fdata, fdata)) - - # Checks that the lp normed is well defined - if not (p == 'inf' or np.isinf(p)) and p < 1: - raise ValueError(f"p must be equal or greater than 1.") - - if isinstance(fdata, FDataBasis): - if fdata.dim_codomain > 1 or p != 2: - raise NotImplementedError - - start, end = fdata.domain_range[0] - integral = scipy.integrate.quad_vec( - lambda x: np.power(np.abs(fdata(x)), p), start, end) - res = np.sqrt(integral[0]).flatten() - - else: - if fdata.dim_codomain > 1: - if p2 == 'inf': - p2 = np.inf - data_matrix = np.linalg.norm(fdata.data_matrix, ord=p2, axis=-1, - keepdims=True) - else: - data_matrix = np.abs(fdata.data_matrix) - - if p == 'inf' or np.isinf(p): - - if fdata.dim_domain == 1: - res = np.max(data_matrix[..., 0], axis=1) - else: - res = np.array([np.max(observation) - for observation in data_matrix]) - - elif fdata.dim_domain == 1: - - # Computes the norm, approximating the integral with Simpson's - # rule. - res = scipy.integrate.simps(data_matrix[..., 0] ** p, - x=fdata.grid_points) ** (1 / p) - - else: - # Needed to perform surface integration - return NotImplemented - - if len(res) == 1: - return res[0] - - return res - - -def lp_distance(fdata1, fdata2, p=2, p2=2, *, eval_points=None, _check=True): - r"""Lp distance for FDataGrid objects. - - Calculates the distance between two functional objects. - - For each pair of observations f and g the distance between them is defined - as: - - .. math:: - d(f, g) = d(g, f) = \| f - g \|_p - - where :math:`\| {}\cdot{} \|_p` denotes the :func:`Lp norm `. - - Args: - fdatagrid (FDataGrid): FDataGrid object. - p (int, optional): p of the lp norm. Must be greater or equal - than 1. If p='inf' or p=np.inf it is used the L infinity metric. - Defaults to 2. - p2 (int, optional): p index of the vectorial norm applied in case of - multivariate objects. Defaults to 2. See :func:`lp_norm`. - - Examples: - Computes the distances between an object containing functional data - corresponding to the functions y = 1 and y = x defined over the - interval [0, 1] and another ones containing data of the functions y - = 0 and y = x/2. The result then is an array 2x2 with the computed - l2 distance between every pair of functions. - - >>> x = np.linspace(0, 1, 1001) - >>> fd = FDataGrid([np.ones(len(x))], x) - >>> fd2 = FDataGrid([np.zeros(len(x))], x) - >>> lp_distance(fd, fd2).round(2) - array([ 1.]) - - - If the functional data are defined over a different set of points of - discretisation the functions returns an exception. - - >>> x = np.linspace(0, 2, 1001) - >>> fd2 = FDataGrid([np.zeros(len(x)), x/2 + 0.5], x) - >>> lp_distance(fd, fd2) - Traceback (most recent call last): - .... - ValueError: ... - - See also: - :func:`~skfda.misc.metrics.l1_distance - :func:`~skfda.misc.metrics.l2_distance - :func:`~skfda.misc.metrics.linf_distance - - """ - _check_compatible(fdata1, fdata2) - - return lp_norm(fdata1 - fdata2, p=p, p2=p2) - - -def l1_distance(fdata1, fdata2, *, eval_points=None, _check=True): - r"""L1 distance for FDataGrid objects. - - Calculates the L1 distance between fdata1 and fdata2: - .. math:: - d(fdata1, fdata2) = - \left( \int_D \| fdata1(x)-fdata2(x) \| dx - \right) - - See also: - :func:`~skfda.misc.metrics.lp_distance - :func:`~skfda.misc.metrics.l2_distance - :func:`~skfda.misc.metrics.linf_distance - """ - return lp_distance(fdata1, fdata2, p=1, p2=1, - eval_points=eval_points, _check=_check) - - -def l2_distance(fdata1, fdata2, *, eval_points=None, _check=True): - r"""L2 distance for FDataGrid objects. - - Calculates the euclidean distance between fdata1 and fdata2: - .. math:: - d(fdata1, fdata2) = - \left( \int_D \| fdata1(x)-fdata2(x) \|^2 dx - \right)^{\frac{1}{2}} - - See also: - :func:`~skfda.misc.metrics.lp_distance - :func:`~skfda.misc.metrics.l1_distance - :func:`~skfda.misc.metrics.linf_distance - """ - return lp_distance(fdata1, fdata2, p=2, p2=2, - eval_points=eval_points, _check=_check) - - -def linf_distance(fdata1, fdata2, *, eval_points=None, _check=True): - r"""L_infinity distance for FDataGrid objects. - - Calculates the L_infinity distance between fdata1 and fdata2: - .. math:: - d(fdata1, fdata2) \equiv \inf \{ C\ge 0 : |fdata1(x)-fdata2(x)| - \le C a.e. \}. - - See also: - :func:`~skfda.misc.metrics.lp_distance - :func:`~skfda.misc.metrics.l1_distance - :func:`~skfda.misc.metrics.l2_distance - """ - return lp_distance(fdata1, fdata2, p=np.inf, p2=np.inf, - eval_points=eval_points, _check=_check) - - -def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, _check=True): - r"""Compute the Fisher-Rao distance between two functional objects. - - Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`q_i` and :math:`q_j` be the corresponding SRSF - (see :class:`SRSF`), the fisher rao distance is defined as - - .. math:: - d_{FR}(f_i, f_j) = \| q_i - q_j \|_2 = - \left ( \int_0^1 sgn(\dot{f_i}(t))\sqrt{|\dot{f_i}(t)|} - - sgn(\dot{f_j}(t))\sqrt{|\dot{f_j}(t)|} dt \right )^{\frac{1}{2}} - - If the observations are distributions of random variables the distance will - match with the usual fisher-rao distance in non-parametric form for - probability distributions [S11-2]_. - - If the observations are defined in a :term:`domain` different than (0,1) - their domains are normalized to this interval with an affine - transformation. - - Args: - fdata1 (FData): First FData object. - fdata2 (FData): Second FData object. - eval_points (array_like, optional): Array with points of evaluation. - - Returns: - Fisher rao distance. - - Raises: - ValueError: If the objects are not unidimensional. - - Refereces: - .. [S11-2] Srivastava, Anuj et. al. Registration of Functional Data - Using Fisher-Rao Metric (2011). In *Function Representation and - Metric* (pp. 5-7). arXiv:1103.3817v2. - - """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - _check=_check) - - # Both should have the same grid points - eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) - - # Calculate the corresponding srsf and normalize to (0,1) - fdata1 = fdata1.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - fdata2 = fdata2.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - - srsf = SRSF(initial_value=0) - fdata1_srsf = srsf.fit_transform(fdata1) - fdata2_srsf = srsf.transform(fdata2) - - # Return the L2 distance of the SRSF - return lp_distance(fdata1_srsf, fdata2_srsf, p=2) - - -def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, - _check=True, **kwargs): - r"""Compute the amplitude distance between two functional objects. - - Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`q_i` and :math:`q_j` be the corresponding SRSF - (see :class:`SRSF`), the amplitude distance is defined as - - .. math:: - d_{A}(f_i, f_j)=min_{\gamma \in \Gamma}d_{FR}(f_i \circ \gamma,f_j) - - A penalty term could be added to restrict the ammount of elasticity in the - alignment used. - - .. math:: - d_{\lambda}^2(f_i, f_j) =min_{\gamma \in \Gamma} \{ - d_{FR}^2(f_i \circ \gamma, f_j) + \lambda \mathcal{R}(\gamma) \} - - - Where :math:`d_{FR}` is the Fisher-Rao distance and the penalty term is - given by - - .. math:: - \mathcal{R}(\gamma) = \|\sqrt{\dot{\gamma}}- 1 \|_{\mathbb{L}^2}^2 - - See [SK16-4-10-1]_ for a detailed explanation. - - If the observations are defined in a :term:`domain` different than (0,1) - their domains are normalized to this interval with an affine - transformation. - - Args: - fdata1 (FData): First FData object. - fdata2 (FData): Second FData object. - lam (float, optional): Penalty term to restric the elasticity. - eval_points (array_like, optional): Array with points of evaluation. - **kwargs (dict): Name arguments to be passed to - :func:`elastic_registration_warping`. - - Returns: - float: Elastic distance. - - Raises: - ValueError: If the objects are not unidimensional. - - Refereces: - .. [SK16-4-10-1] Srivastava, Anuj & Klassen, Eric P. (2016). - Functional and shape data analysis. In *Amplitude Space and a - Metric Structure* (pp. 107-109). Springer. - """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - _check=_check) - - # Both should have the same grid points - eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) - - # Calculate the corresponding srsf and normalize to (0,1) - fdata1 = fdata1.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - fdata2 = fdata2.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - - elastic_registration = ElasticRegistration( - template=fdata2, - penalty=lam, - output_points=eval_points_normalized, - **kwargs) - - fdata1_reg = elastic_registration.fit_transform(fdata1) - - srsf = SRSF(initial_value=0) - fdata1_reg_srsf = srsf.fit_transform(fdata1_reg) - fdata2_srsf = srsf.transform(fdata2) - distance = lp_distance(fdata1_reg_srsf, fdata2_srsf) - - if lam != 0.0: - # L2 norm || sqrt(Dh) - 1 ||^2 - warping_deriv = elastic_registration.warping_.derivative() - penalty = warping_deriv(eval_points_normalized)[0, ..., 0] - penalty = np.sqrt(penalty, out=penalty) - penalty -= 1 - penalty = np.square(penalty, out=penalty) - penalty = scipy.integrate.simps(penalty, x=eval_points_normalized) - - distance = np.sqrt(distance**2 + lam * penalty) - - return distance - - -def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, _check=True, - **kwargs): - r"""Compute the phase distance between two functional objects. - - Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`\gamma_{ij}` the corresponding warping used in the elastic - registration to align :math:`f_i` to :math:`f_j` (see - :func:`elastic_registration`). The phase distance between :math:`f_i` - and :math:`f_j` is defined as - - .. math:: - d_{P}(f_i, f_j) = d_{FR}(\gamma_{ij}, \gamma_{id}) = - arcos \left ( \int_0^1 \sqrt {\dot \gamma_{ij}(t)} dt \right ) - - See [SK16-4-10-2]_ for a detailed explanation. - - If the observations are defined in a :term:`domain` different than (0,1) - their domains are normalized to this interval with an affine - transformation. - - Args: - fdata1 (FData): First FData object. - fdata2 (FData): Second FData object. - lambda (float, optional): Penalty term to restric the elasticity. - eval_points (array_like, optional): Array with points of evaluation. - **kwargs (dict): Name arguments to be passed to - :func:`elastic_registration_warping`. - - Returns: - float: Phase distance between the objects. - - Raises: - ValueError: If the objects are not unidimensional. - - Refereces: - .. [SK16-4-10-2] Srivastava, Anuj & Klassen, Eric P. (2016). - Functional and shape data analysis. In *Phase Space and a Metric - Structure* (pp. 109-111). Springer. - """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - _check=_check) - - # Rescale in (0,1) - eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) - - # Calculate the corresponding srsf and normalize to (0,1) - fdata1 = fdata1.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - fdata2 = fdata2.copy(grid_points=eval_points_normalized, - domain_range=(0, 1)) - - elastic_registration = ElasticRegistration( - penalty=lam, template=fdata2, - output_points=eval_points_normalized) - - elastic_registration.fit_transform(fdata1) - - warping_deriv = elastic_registration.warping_.derivative() - derivative_warping = warping_deriv(eval_points_normalized)[0, ..., 0] - - derivative_warping = np.sqrt(derivative_warping, out=derivative_warping) - - d = scipy.integrate.simps(derivative_warping, x=eval_points_normalized) - d = np.clip(d, -1, 1) - - return np.arccos(d) - - -def warping_distance(warping1, warping2, *, eval_points=None, _check=True): - r"""Compute the distance between warpings functions. - - Let :math:`\gamma_i` and :math:`\gamma_j` be two warpings, defined in - :math:`\gamma_i:[a,b] \rightarrow [a,b]`. The distance in the - space of warping functions, :math:`\Gamma`, with the riemannian metric - given by the fisher-rao inner product can be computed using the structure - of hilbert sphere in their srsf's. - - .. math:: - d_{\Gamma}(\gamma_i, \gamma_j) = cos^{-1} \left ( \int_0^1 - \sqrt{\dot \gamma_i(t)\dot \gamma_j(t)}dt \right ) - - See [SK16-4-11-2]_ for a detailed explanation. - - If the warpings are not defined in [0,1], an affine transformation is maked - to change the :term:`domain`. - - Args: - fdata1 (:obj:`FData`): First warping. - fdata2 (:obj:`FData`): Second warping. - eval_points (array_like, optional): Array with points of evaluation. - - Returns: - float: Distance between warpings: - - Raises: - ValueError: If the objects are not unidimensional. - - Refereces: - .. [SK16-4-11-2] Srivastava, Anuj & Klassen, Eric P. (2016). - Functional and shape data analysis. In *Probability Density - Functions* (pp. 113-117). Springer. - - """ - warping1, warping2 = _cast_to_grid(warping1, warping2, - eval_points=eval_points, _check=_check) - - # Normalization of warping to (0,1)x(0,1) - warping1 = normalize_warping(warping1, (0, 1)) - warping2 = normalize_warping(warping2, (0, 1)) - - warping1_data = warping1.derivative().data_matrix[0, ..., 0] - warping2_data = warping2.derivative().data_matrix[0, ..., 0] - - # Derivative approximations can have negatives, specially in the - # borders. - warping1_data[warping1_data < 0] = 0 - warping2_data[warping2_data < 0] = 0 - - # In this case the srsf is the sqrt(gamma') - srsf_warping1 = np.sqrt(warping1_data, out=warping1_data) - srsf_warping2 = np.sqrt(warping2_data, out=warping2_data) - - product = np.multiply(srsf_warping1, srsf_warping2, out=srsf_warping1) - - d = scipy.integrate.simps(product, x=warping1.grid_points[0]) - d = np.clip(d, -1, 1) - - return np.arccos(d) diff --git a/skfda/misc/metrics/__init__.py b/skfda/misc/metrics/__init__.py new file mode 100644 index 000000000..d8dc69ebd --- /dev/null +++ b/skfda/misc/metrics/__init__.py @@ -0,0 +1,22 @@ +"""Metrics, norms and related utilities.""" + +from ._elastic_metrics import ( + amplitude_distance, + fisher_rao_distance, + phase_distance, + warping_distance, +) +from ._lp_distances import ( + LpDistance, + l1_distance, + l2_distance, + linf_distance, + lp_distance, +) +from ._lp_norms import LpNorm, l1_norm, l2_norm, linf_norm, lp_norm +from ._typing import PRECOMPUTED, Metric, Norm +from ._utils import ( + NormInducedMetric, + PairwiseMetric, + pairwise_metric_optimization, +) diff --git a/skfda/misc/metrics/_elastic_metrics.py b/skfda/misc/metrics/_elastic_metrics.py new file mode 100644 index 000000000..8025d4d4e --- /dev/null +++ b/skfda/misc/metrics/_elastic_metrics.py @@ -0,0 +1,345 @@ +"""Elastic metrics.""" + +from typing import Any, TypeVar + +import numpy as np +import scipy.integrate + +from ...preprocessing.registration import ( + ElasticRegistration, + normalize_warping, +) +from ...preprocessing.registration._warping import _normalize_scale +from ...preprocessing.registration.elastic import SRSF +from ...representation import FData +from ._lp_distances import l2_distance +from ._utils import _cast_to_grid + +T = TypeVar("T", bound=FData) + + +def fisher_rao_distance( + fdata1: T, + fdata2: T, + *, + eval_points: np.ndarray = None, + _check: bool = True, +) -> np.ndarray: + r"""Compute the Fisher-Rao distance between two functional objects. + + Let :math:`f_i` and :math:`f_j` be two functional observations, and let + :math:`q_i` and :math:`q_j` be the corresponding SRSF + (see :class:`SRSF`), the fisher rao distance is defined as + + .. math:: + d_{FR}(f_i, f_j) = \| q_i - q_j \|_2 = + \left ( \int_0^1 sgn(\dot{f_i}(t))\sqrt{|\dot{f_i}(t)|} - + sgn(\dot{f_j}(t))\sqrt{|\dot{f_j}(t)|} dt \right )^{\frac{1}{2}} + + If the observations are distributions of random variables the distance will + match with the usual fisher-rao distance in non-parametric form for + probability distributions :footcite:`srivastava++_2011_ficher-rao`. + + If the observations are defined in a :term:`domain` different than (0,1) + their domains are normalized to this interval with an affine + transformation. + + Args: + fdata1: First FData object. + fdata2: Second FData object. + eval_points: Array with points of evaluation. + + Returns: + Fisher rao distance. + + Raises: + ValueError: If the objects are not unidimensional. + + References: + .. footbibliography:: + + """ + fdata1, fdata2 = _cast_to_grid( + fdata1, + fdata2, + eval_points=eval_points, + _check=_check, + ) + + # Both should have the same grid points + eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) + + # Calculate the corresponding srsf and normalize to (0,1) + fdata1 = fdata1.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + fdata2 = fdata2.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + + srsf = SRSF(initial_value=0) + fdata1_srsf = srsf.fit_transform(fdata1) + fdata2_srsf = srsf.transform(fdata2) + + # Return the L2 distance of the SRSF + return l2_distance(fdata1_srsf, fdata2_srsf) + + +def amplitude_distance( + fdata1: T, + fdata2: T, + *, + lam: float = 0.0, + eval_points: np.ndarray = None, + _check: bool = True, + **kwargs: Any, +) -> np.ndarray: + r"""Compute the amplitude distance between two functional objects. + + Let :math:`f_i` and :math:`f_j` be two functional observations, and let + :math:`q_i` and :math:`q_j` be the corresponding SRSF + (see :class:`SRSF`), the amplitude distance is defined as + + .. math:: + d_{A}(f_i, f_j)=min_{\gamma \in \Gamma}d_{FR}(f_i \circ \gamma,f_j) + + A penalty term could be added to restrict the ammount of elasticity in the + alignment used. + + .. math:: + d_{\lambda}^2(f_i, f_j) =min_{\gamma \in \Gamma} \{ + d_{FR}^2(f_i \circ \gamma, f_j) + \lambda \mathcal{R}(\gamma) \} + + + Where :math:`d_{FR}` is the Fisher-Rao distance and the penalty term is + given by + + .. math:: + \mathcal{R}(\gamma) = \|\sqrt{\dot{\gamma}}- 1 \|_{\mathbb{L}^2}^2 + + See the :footcite:`srivastava+klassen_2016_analysis_amplitude` for a + detailed explanation. + + If the observations are defined in a :term:`domain` different than (0,1) + their domains are normalized to this interval with an affine + transformation. + + Args: + fdata1: First FData object. + fdata2: Second FData object. + lam: Penalty term to restric the elasticity. + eval_points: Array with points of evaluation. + kwargs: Name arguments to be passed to + :func:`elastic_registration_warping`. + + Returns: + Elastic distance. + + Raises: + ValueError: If the objects are not unidimensional. + + References: + .. footbibliography:: + + """ + fdata1, fdata2 = _cast_to_grid( + fdata1, + fdata2, + eval_points=eval_points, + _check=_check, + ) + + # Both should have the same grid points + eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) + + # Calculate the corresponding srsf and normalize to (0,1) + fdata1 = fdata1.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + fdata2 = fdata2.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + + elastic_registration = ElasticRegistration( + template=fdata2, + penalty=lam, + output_points=eval_points_normalized, + **kwargs, + ) + + fdata1_reg = elastic_registration.fit_transform(fdata1) + + srsf = SRSF(initial_value=0) + fdata1_reg_srsf = srsf.fit_transform(fdata1_reg) + fdata2_srsf = srsf.transform(fdata2) + distance = l2_distance(fdata1_reg_srsf, fdata2_srsf) + + if lam != 0.0: + # L2 norm || sqrt(Dh) - 1 ||^2 + warping_deriv = elastic_registration.warping_.derivative() + penalty = warping_deriv(eval_points_normalized)[0, ..., 0] + penalty = np.sqrt(penalty, out=penalty) + penalty -= 1 + penalty = np.square(penalty, out=penalty) + penalty = scipy.integrate.simps(penalty, x=eval_points_normalized) + + distance = np.sqrt(distance**2 + lam * penalty) + + return distance + + +def phase_distance( + fdata1: T, + fdata2: T, + *, + lam: float = 0.0, + eval_points: np.ndarray = None, + _check: bool = True, +) -> np.ndarray: + r"""Compute the phase distance between two functional objects. + + Let :math:`f_i` and :math:`f_j` be two functional observations, and let + :math:`\gamma_{ij}` the corresponding warping used in the elastic + registration to align :math:`f_i` to :math:`f_j` (see + :func:`elastic_registration`). The phase distance between :math:`f_i` + and :math:`f_j` is defined as + + .. math:: + d_{P}(f_i, f_j) = d_{FR}(\gamma_{ij}, \gamma_{id}) = + arcos \left ( \int_0^1 \sqrt {\dot \gamma_{ij}(t)} dt \right ) + + See :footcite:`srivastava+klassen_2016_analysis_phase` for a detailed + explanation. + + If the observations are defined in a :term:`domain` different than (0,1) + their domains are normalized to this interval with an affine + transformation. + + Args: + fdata1: First FData object. + fdata2: Second FData object. + lam: Penalty term to restric the elasticity. + eval_points (array_like, optional): Array with points of evaluation. + + Returns: + Phase distance between the objects. + + Raises: + ValueError: If the objects are not unidimensional. + + References: + .. footbibliography:: + + """ + fdata1, fdata2 = _cast_to_grid( + fdata1, + fdata2, + eval_points=eval_points, + _check=_check, + ) + + # Rescale in the interval (0,1) + eval_points_normalized = _normalize_scale(fdata1.grid_points[0]) + + # Calculate the corresponding srsf and normalize to (0,1) + fdata1 = fdata1.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + fdata2 = fdata2.copy( + grid_points=eval_points_normalized, + domain_range=(0, 1), + ) + + elastic_registration = ElasticRegistration( + penalty=lam, + template=fdata2, + output_points=eval_points_normalized, + ) + + elastic_registration.fit_transform(fdata1) + + warping_deriv = elastic_registration.warping_.derivative() + derivative_warping = warping_deriv(eval_points_normalized)[0, ..., 0] + + derivative_warping = np.sqrt(derivative_warping, out=derivative_warping) + + d = scipy.integrate.simps(derivative_warping, x=eval_points_normalized) + d = np.clip(d, -1, 1) + + return np.arccos(d) + + +def warping_distance( + warping1: T, + warping2: T, + *, + eval_points: np.ndarray = None, + _check: bool = True, +) -> np.ndarray: + r"""Compute the distance between warpings functions. + + Let :math:`\gamma_i` and :math:`\gamma_j` be two warpings, defined in + :math:`\gamma_i:[a,b] \rightarrow [a,b]`. The distance in the + space of warping functions, :math:`\Gamma`, with the riemannian metric + given by the fisher-rao inner product can be computed using the structure + of hilbert sphere in their srsf's. + + .. math:: + d_{\Gamma}(\gamma_i, \gamma_j) = cos^{-1} \left ( \int_0^1 + \sqrt{\dot \gamma_i(t)\dot \gamma_j(t)}dt \right ) + + See :footcite:`srivastava+klassen_2016_analysis_probability` for a detailed + explanation. + + If the warpings are not defined in [0,1], an affine transformation is maked + to change the :term:`domain`. + + Args: + warping1: First warping. + warping2: Second warping. + eval_points: Array with points of evaluation. + + Returns: + Distance between warpings: + + Raises: + ValueError: If the objects are not unidimensional. + + References: + .. footbibliography:: + + """ + warping1, warping2 = _cast_to_grid( + warping1, + warping2, + eval_points=eval_points, + _check=_check, + ) + + # Normalization of warping to (0,1)x(0,1) + warping1 = normalize_warping(warping1, (0, 1)) + warping2 = normalize_warping(warping2, (0, 1)) + + warping1_data = warping1.derivative().data_matrix[0, ..., 0] + warping2_data = warping2.derivative().data_matrix[0, ..., 0] + + # Derivative approximations can have negatives, specially in the + # borders. + warping1_data[warping1_data < 0] = 0 + warping2_data[warping2_data < 0] = 0 + + # In this case the srsf is the sqrt(gamma') + srsf_warping1 = np.sqrt(warping1_data, out=warping1_data) + srsf_warping2 = np.sqrt(warping2_data, out=warping2_data) + + product = np.multiply(srsf_warping1, srsf_warping2, out=srsf_warping1) + + d = scipy.integrate.simps(product, x=warping1.grid_points[0]) + d = np.clip(d, -1, 1) + + return np.arccos(d) diff --git a/skfda/misc/metrics/_lp_distances.py b/skfda/misc/metrics/_lp_distances.py new file mode 100644 index 000000000..badf3f76c --- /dev/null +++ b/skfda/misc/metrics/_lp_distances.py @@ -0,0 +1,211 @@ + +"""Implementation of Lp distances.""" + +import math +from typing import Optional, TypeVar, Union + +import numpy as np +from typing_extensions import Final + +from ...representation import FData +from ._lp_norms import LpNorm +from ._typing import Norm +from ._utils import NormInducedMetric, pairwise_metric_optimization + +T = TypeVar("T", bound=FData) + + +class LpDistance(NormInducedMetric[FData]): + r"""Lp distance for FDataGrid objects. + + Calculates the distance between two functional objects. + + For each pair of observations f and g the distance between them is defined + as: + + .. math:: + d(f, g) = d(g, f) = \| f - g \|_p + + where :math:`\| {}\cdot{} \|_p` denotes the :func:`Lp norm `. + + The objects ``l1_distance``, ``l2_distance`` and ``linf_distance`` are + instances of this class with commonly used values of ``p``, namely 1, 2 and + infinity. + + Args: + p: p of the lp norm. Must be greater or equal + than 1. If ``p=math.inf`` it is used the L infinity metric. + Defaults to 2. + vector_norm: vector norm to apply. If it is a float, is the index of + the multivariate lp norm. Defaults to the same as ``p``. + + Examples: + Computes the distances between an object containing functional data + corresponding to the functions y = 1 and y = x defined over the + interval [0, 1] and another ones containing data of the functions y + = 0 and y = x/2. The result then is an array 2x2 with the computed + l2 distance between every pair of functions. + + >>> import skfda + >>> import numpy as np + >>> + >>> x = np.linspace(0, 1, 1001) + >>> fd = skfda.FDataGrid([np.ones(len(x))], x) + >>> fd2 = skfda.FDataGrid([np.zeros(len(x))], x) + >>> + >>> distance = skfda.misc.metrics.LpDistance(p=2) + >>> distance(fd, fd2).round(2) + array([ 1.]) + + + If the functional data are defined over a different set of points of + discretisation the functions returns an exception. + + >>> x = np.linspace(0, 2, 1001) + >>> fd2 = skfda.FDataGrid([np.zeros(len(x)), x/2 + 0.5], x) + >>> distance = skfda.misc.metrics.LpDistance(p=2) + >>> distance(fd, fd2) + Traceback (most recent call last): + ... + ValueError: ... + + """ # noqa: P102 + + def __init__( + self, + p: float, + vector_norm: Union[Norm[np.ndarray], float, None] = None, + ) -> None: + + self.p = p + self.vector_norm = vector_norm + norm = LpNorm(p=p, vector_norm=vector_norm) + + super().__init__(norm) + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(" + f"p={self.p}, vector_norm={self.vector_norm})" + ) + + +l1_distance: Final = LpDistance(p=1) +l2_distance: Final = LpDistance(p=2) +linf_distance: Final = LpDistance(p=math.inf) + + +@pairwise_metric_optimization.register +def _pairwise_metric_optimization_lp_fdata( + metric: LpDistance, + elem1: FData, + elem2: Optional[FData], +) -> np.ndarray: + from ...misc import inner_product, inner_product_matrix + + vector_norm = metric.vector_norm + + if vector_norm is None: + vector_norm = metric.p + + # Special case, the inner product is heavily optimized + if metric.p == vector_norm == 2: + diag1 = inner_product(elem1, elem1) + diag2 = diag1 if elem2 is None else inner_product(elem2, elem2) + + if elem2 is None: + elem2 = elem1 + + inner_matrix = inner_product_matrix(elem1, elem2) + + distance_matrix_sqr = ( + -2 * inner_matrix + + diag1[:, np.newaxis] + + diag2[np.newaxis, :] + ) + + np.clip( + distance_matrix_sqr, + a_min=0, + a_max=None, + out=distance_matrix_sqr, + ) + + return np.sqrt(distance_matrix_sqr) + + return NotImplemented + + +def lp_distance( + fdata1: T, + fdata2: T, + *, + p: float, + vector_norm: Union[Norm[np.ndarray], float, None] = None, +) -> np.ndarray: + r""" + Lp distance for FDataGrid objects. + + Calculates the distance between two functional objects. + + For each pair of observations f and g the distance between them is defined + as: + + .. math:: + d(f, g) = d(g, f) = \| f - g \|_p + + where :math:`\| {}\cdot{} \|_p` denotes the :func:`Lp norm `. + + Note: + This function is a wrapper of :class:`LpDistance`, available only for + convenience. As the parameter ``p`` is mandatory, it cannot be used + where a fully-defined metric is required: use an instance of + :class:`LpDistance` in those cases. + + Args: + fdata1: First FData object. + fdata2: Second FData object. + p: p of the lp norm. Must be greater or equal + than 1. If ``p=math.inf`` it is used the L infinity metric. + Defaults to 2. + vector_norm: vector norm to apply. If it is a float, is the index of + the multivariate lp norm. Defaults to the same as ``p``. + + Returns: + Numpy vector where the i-th coordinate has the distance between the + i-th element of the first object and the i-th element of the second + one. + + Examples: + Computes the distances between an object containing functional data + corresponding to the functions y = 1 and y = x defined over the + interval [0, 1] and another ones containing data of the functions y + = 0 and y = x/2. The result then is an array of size 2 with the + computed l2 distance between the functions in the same position in + both. + + >>> import skfda + >>> import numpy as np + >>> + >>> x = np.linspace(0, 1, 1001) + >>> fd = skfda.FDataGrid([np.ones(len(x)), x], x) + >>> fd2 = skfda.FDataGrid([np.zeros(len(x)), x/2], x) + >>> + >>> skfda.misc.metrics.lp_distance(fd, fd2, p=2).round(2) + array([ 1. , 0.29]) + + If the functional data are defined over a different set of points of + discretisation the functions returns an exception. + + >>> x = np.linspace(0, 2, 1001) + >>> fd2 = skfda.FDataGrid([np.zeros(len(x)), x/2 + 0.5], x) + >>> skfda.misc.metrics.lp_distance(fd, fd2, p=2) + Traceback (most recent call last): + ... + ValueError: ... + + See also: + :class:`~skfda.misc.metrics.LpDistance` + + """ # noqa: P102 + return LpDistance(p=p, vector_norm=vector_norm)(fdata1, fdata2) diff --git a/skfda/misc/metrics/_lp_norms.py b/skfda/misc/metrics/_lp_norms.py new file mode 100644 index 000000000..e2db76cda --- /dev/null +++ b/skfda/misc/metrics/_lp_norms.py @@ -0,0 +1,264 @@ +"""Implementation of Lp norms.""" +import math +from builtins import isinstance +from typing import Union + +import numpy as np +import scipy.integrate +from typing_extensions import Final + +from ...representation import FData, FDataBasis +from ._typing import Norm + + +class LpNorm(Norm[FData]): + r""" + Norm of all the observations in a FDataGrid object. + + For each observation f the Lp norm is defined as: + + .. math:: + \| f \| = \left( \int_D \| f \|^p dx \right)^{ + \frac{1}{p}} + + Where D is the :term:`domain` over which the functions are defined. + + The integral is approximated using Simpson's rule. + + In general, if f is a multivariate function :math:`(f_1, ..., f_d)`, and + :math:`D \subset \mathbb{R}^n`, it is applied the following generalization + of the Lp norm. + + .. math:: + \| f \| = \left( \int_D \| f \|_{*}^p dx \right)^{ + \frac{1}{p}} + + Where :math:`\| \cdot \|_*` denotes a vectorial norm. See + :func:`vectorial_norm` to more information. + + For example, if :math:`f: \mathbb{R}^2 \rightarrow \mathbb{R}^2`, and + :math:`\| \cdot \|_*` is the euclidean norm + :math:`\| (x,y) \|_* = \sqrt{x^2 + y^2}`, the lp norm applied is + + .. math:: + \| f \| = \left( \int \int_D \left ( \sqrt{ \| f_1(x,y) + \|^2 + \| f_2(x,y) \|^2 } \right )^p dxdy \right)^{ + \frac{1}{p}} + + The objects ``l1_norm``, ``l2_norm`` and ``linf_norm`` are instances of + this class with commonly used values of ``p``, namely 1, 2 and infinity. + + Args: + p: p of the lp norm. Must be greater or equal + than 1. If ``p=math.inf`` it is used the L infinity metric. + Defaults to 2. + vector_norm: vector norm to apply. If it is a float, is the index of + the multivariate lp norm. Defaults to the same as ``p``. + + Examples: + Calculates the norm of a FDataGrid containing the functions y = 1 + and y = x defined in the interval [0,1]. + + >>> import skfda + >>> import numpy as np + >>> + >>> x = np.linspace(0, 1, 1001) + >>> fd = skfda.FDataGrid([np.ones(len(x)), x] ,x) + >>> norm = skfda.misc.metrics.LpNorm(2) + >>> norm(fd).round(2) + array([ 1. , 0.58]) + + As the norm with `p=2` is a common choice, one can use `l2_norm` + directly: + + >>> skfda.misc.metrics.l2_norm(fd).round(2) + array([ 1. , 0.58]) + + The lp norm is only defined if p >= 1. + + >>> norm = skfda.misc.metrics.LpNorm(0.5) + Traceback (most recent call last): + .... + ValueError: p (=0.5) must be equal or greater than 1. + + """ + + def __init__( + self, + p: float, + vector_norm: Union[Norm[np.ndarray], float, None] = None, + ) -> None: + + # Checks that the lp normed is well defined + if not np.isinf(p) and p < 1: + raise ValueError(f"p (={p}) must be equal or greater than 1.") + + self.p = p + self.vector_norm = vector_norm + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(" + f"p={self.p}, vector_norm={self.vector_norm})" + ) + + def __call__(self, fdata: FData) -> np.ndarray: + """Compute the Lp norm of a functional data object.""" + from ...misc import inner_product + + vector_norm = self.vector_norm + + if vector_norm is None: + vector_norm = self.p + + # Special case, the inner product is heavily optimized + if self.p == vector_norm == 2: + return np.sqrt(inner_product(fdata, fdata)) + + if isinstance(fdata, FDataBasis): + if self.p != 2: + raise NotImplementedError + + start, end = fdata.domain_range[0] + integral = scipy.integrate.quad_vec( + lambda x: np.power(np.abs(fdata(x)), self.p), + start, + end, + ) + res = np.sqrt(integral[0]).flatten() + + else: + data_matrix = fdata.data_matrix + original_shape = data_matrix.shape + data_matrix = data_matrix.reshape(-1, original_shape[-1]) + + data_matrix = (np.linalg.norm( + fdata.data_matrix, + ord=vector_norm, + axis=-1, + keepdims=True, + ) if isinstance(vector_norm, (float, int)) + else vector_norm(data_matrix) + ) + data_matrix = data_matrix.reshape(original_shape[:-1] + (1,)) + + if np.isinf(self.p): + + res = np.max( + data_matrix, + axis=tuple(range(1, data_matrix.ndim)), + ) + + elif fdata.dim_domain == 1: + + # Computes the norm, approximating the integral with Simpson's + # rule. + res = scipy.integrate.simps( + data_matrix[..., 0] ** self.p, + x=fdata.grid_points, + ) ** (1 / self.p) + + else: + # Needed to perform surface integration + return NotImplemented + + if len(res) == 1: + return res[0] + + return res + + +l1_norm: Final = LpNorm(1) +l2_norm: Final = LpNorm(2) +linf_norm: Final = LpNorm(math.inf) + + +def lp_norm( + fdata: FData, + *, + p: float, + vector_norm: Union[Norm[np.ndarray], float, None] = None, +) -> np.ndarray: + r"""Calculate the norm of all the observations in a FDataGrid object. + + For each observation f the Lp norm is defined as: + + .. math:: + \| f \| = \left( \int_D \| f \|^p dx \right)^{ + \frac{1}{p}} + + Where D is the :term:`domain` over which the functions are defined. + + The integral is approximated using Simpson's rule. + + In general, if f is a multivariate function :math:`(f_1, ..., f_d)`, and + :math:`D \subset \mathbb{R}^n`, it is applied the following generalization + of the Lp norm. + + .. math:: + \| f \| = \left( \int_D \| f \|_{*}^p dx \right)^{ + \frac{1}{p}} + + Where :math:`\| \cdot \|_*` denotes a vectorial norm. See + :func:`vectorial_norm` to more information. + + For example, if :math:`f: \mathbb{R}^2 \rightarrow \mathbb{R}^2`, and + :math:`\| \cdot \|_*` is the euclidean norm + :math:`\| (x,y) \|_* = \sqrt{x^2 + y^2}`, the lp norm applied is + + .. math:: + \| f \| = \left( \int \int_D \left ( \sqrt{ \| f_1(x,y) + \|^2 + \| f_2(x,y) \|^2 } \right )^p dxdy \right)^{ + \frac{1}{p}} + + Note: + This function is a wrapper of :class:`LpNorm`, available only for + convenience. As the parameter ``p`` is mandatory, it cannot be used + where a fully-defined norm is required: use an instance of + :class:`LpNorm` in those cases. + + Args: + fdata: FData object. + p: p of the lp norm. Must be greater or equal + than 1. If ``p=math.inf`` it is used the L infinity metric. + Defaults to 2. + vector_norm: vector norm to apply. If it is a float, is the index of + the multivariate lp norm. Defaults to the same as ``p``. + + Returns: + numpy.darray: Matrix with as many rows as observations in the first + object and as many columns as observations in the second one. Each + element (i, j) of the matrix is the inner product of the ith + observation of the first object and the jth observation of the second + one. + + Examples: + Calculates the norm of a FDataGrid containing the functions y = 1 + and y = x defined in the interval [0,1]. + + >>> import skfda + >>> import numpy as np + >>> + >>> x = np.linspace(0,1,1001) + >>> fd = skfda.FDataGrid([np.ones(len(x)), x] ,x) + >>> skfda.misc.metrics.lp_norm(fd, p=2).round(2) + array([ 1. , 0.58]) + + As the norm with ``p=2`` is a common choice, one can use ``l2_norm`` + directly: + + >>> skfda.misc.metrics.l2_norm(fd).round(2) + array([ 1. , 0.58]) + + The lp norm is only defined if p >= 1. + + >>> skfda.misc.metrics.lp_norm(fd, p=0.5) + Traceback (most recent call last): + .... + ValueError: p (=0.5) must be equal or greater than 1. + + See also: + :class:`LpNorm` + + """ + return LpNorm(p=p, vector_norm=vector_norm)(fdata) diff --git a/skfda/misc/metrics/_typing.py b/skfda/misc/metrics/_typing.py new file mode 100644 index 000000000..1b3e76c84 --- /dev/null +++ b/skfda/misc/metrics/_typing.py @@ -0,0 +1,75 @@ +"""Typing for norms and metrics.""" +import enum +from abc import abstractmethod +from builtins import isinstance +from typing import Any, TypeVar, Union, overload + +import numpy as np +from typing_extensions import Final, Literal, Protocol + +from ...representation._typing import Vector + +VectorType = TypeVar("VectorType", contravariant=True, bound=Vector) +MetricElementType = TypeVar("MetricElementType", contravariant=True) + + +class _MetricSingletons(enum.Enum): + PRECOMPUTED = "precomputed" + + +PRECOMPUTED: Final = _MetricSingletons.PRECOMPUTED + +_PrecomputedTypes = Literal[ + _MetricSingletons.PRECOMPUTED, + "precomputed", +] + + +class Norm(Protocol[VectorType]): + """Protocol for a norm of a vector.""" + + @abstractmethod + def __call__(self, __vector: VectorType) -> np.ndarray: # noqa: WPS112 + """Compute the norm of a vector.""" + + +class Metric(Protocol[MetricElementType]): + """Protocol for a metric between two elements of a metric space.""" + + @abstractmethod + def __call__( + self, + __e1: MetricElementType, # noqa: WPS112 + __e2: MetricElementType, # noqa: WPS112 + ) -> np.ndarray: + """Compute the norm of a vector.""" + + +_NonStringMetric = TypeVar( + "_NonStringMetric", + bound=Union[ + Metric[Any], + _MetricSingletons, + ], +) + + +@overload +def _parse_metric( + metric: str, +) -> _MetricSingletons: + pass + + +@overload +def _parse_metric( + metric: _NonStringMetric, +) -> _NonStringMetric: + pass + + +def _parse_metric( + metric: Union[Metric[Any], _MetricSingletons, str], +) -> Union[Metric[Any], _MetricSingletons]: + + return _MetricSingletons(metric) if isinstance(metric, str) else metric diff --git a/skfda/misc/metrics/_utils.py b/skfda/misc/metrics/_utils.py new file mode 100644 index 000000000..cced983ac --- /dev/null +++ b/skfda/misc/metrics/_utils.py @@ -0,0 +1,187 @@ +"""Utilities for norms and metrics.""" +from typing import Any, Generic, Optional, Tuple, TypeVar + +import multimethod +import numpy as np + +from ..._utils import _pairwise_symmetric +from ...representation import FData, FDataGrid +from ._typing import Metric, MetricElementType, Norm, VectorType + +T = TypeVar("T", bound=FData) + + +def _check_compatible(fdata1: T, fdata2: T) -> None: + + if isinstance(fdata1, FData) and isinstance(fdata2, FData): + if ( + fdata2.dim_codomain != fdata1.dim_codomain + or fdata2.dim_domain != fdata1.dim_domain + ): + raise ValueError("Objects should have the same dimensions") + + if not np.array_equal(fdata1.domain_range, fdata2.domain_range): + raise ValueError("Domain ranges for both objects must be equal") + + +def _cast_to_grid( + fdata1: FData, + fdata2: FData, + eval_points: np.ndarray = None, + _check: bool = True, +) -> Tuple[FDataGrid, FDataGrid]: + """Convert fdata1 and fdata2 to FDatagrid. + + Checks if the fdatas passed as argument are unidimensional and compatible + and converts them to FDatagrid to compute their distances. + + Args: + fdata1: First functional object. + fdata2: Second functional object. + eval_points: Evaluation points. + + Returns: + Tuple with two :obj:`FDataGrid` with the same grid points. + """ + # Dont perform any check + if not _check: + return fdata1, fdata2 + + _check_compatible(fdata1, fdata2) + + # Case new evaluation points specified + if eval_points is not None: # noqa: WPS223 + fdata1 = fdata1.to_grid(eval_points) + fdata2 = fdata2.to_grid(eval_points) + + elif not isinstance(fdata1, FDataGrid) and isinstance(fdata2, FDataGrid): + fdata1 = fdata1.to_grid(fdata2.grid_points[0]) + + elif not isinstance(fdata2, FDataGrid) and isinstance(fdata1, FDataGrid): + fdata2 = fdata2.to_grid(fdata1.grid_points[0]) + + elif ( + not isinstance(fdata1, FDataGrid) + and not isinstance(fdata2, FDataGrid) + ): + domain = fdata1.domain_range[0] + grid_points = np.linspace(*domain) + fdata1 = fdata1.to_grid(grid_points) + fdata2 = fdata2.to_grid(grid_points) + + elif not np.array_equal( + fdata1.grid_points, + fdata2.grid_points, + ): + raise ValueError( + "Grid points for both objects must be equal or" + "a new list evaluation points must be specified", + ) + + return fdata1, fdata2 + + +class NormInducedMetric(Metric[VectorType]): + r""" + Metric induced by a norm. + + Given a norm :math:`\| \cdot \|: X \rightarrow \mathbb{R}`, + returns the metric :math:`d: X \times X \rightarrow \mathbb{R}` induced + by the norm: + + .. math:: + d(f,g) = \|f - g\| + + Args: + norm: Norm used to induce the metric. + + Examples: + Computes the :math:`\mathbb{L}^2` distance between an object containing + functional data corresponding to the function :math:`y(x) = x` defined + over the interval [0, 1] and another one containing data of the + function :math:`y(x) = x/2`. + + Firstly we create the functional data. + + >>> import skfda + >>> import numpy as np + >>> from skfda.misc.metrics import l2_norm, NormInducedMetric + >>> + >>> x = np.linspace(0, 1, 1001) + >>> fd = skfda.FDataGrid([x], x) + >>> fd2 = skfda.FDataGrid([x/2], x) + + To construct the :math:`\mathbb{L}^2` distance it is used the + :math:`\mathbb{L}^2` norm wich it is used to compute the distance. + + >>> l2_distance = NormInducedMetric(l2_norm) + >>> d = l2_distance(fd, fd2) + >>> float('%.3f'% d) + 0.289 + + """ + + def __init__(self, norm: Norm[VectorType]): + self.norm = norm + + def __call__(self, elem1: VectorType, elem2: VectorType) -> np.ndarray: + """Compute the induced norm between two vectors.""" + return self.norm(elem1 - elem2) + + def __repr__(self) -> str: + return f"{type(self).__name__}(norm={self.norm})" + + +@multimethod.multidispatch +def pairwise_metric_optimization( + metric: Any, + elem1: Any, + elem2: Optional[Any], +) -> np.ndarray: + r""" + Optimized computation of a pairwise metric. + + This is a generic function that can be subclassed for different + combinations of metric and operators in order to provide a more + efficient implementation for the pairwise metric matrix. + """ + return NotImplemented + + +class PairwiseMetric(Generic[MetricElementType]): + r"""Pairwise metric function. + + Computes a given metric pairwise. The matrix returned by the pairwise + metric is a matrix with as many rows as observations in the first object + and as many columns as observations in the second one. Each element + (i, j) of the matrix is the distance between the ith observation of the + first object and the jth observation of the second one. + + Args: + metric: Metric between two elements of a metric + space. + + """ + + def __init__( + self, + metric: Metric[MetricElementType], + ): + self.metric = metric + + def __call__( + self, + elem1: MetricElementType, + elem2: Optional[MetricElementType] = None, + ) -> np.ndarray: + """Evaluate the pairwise metric.""" + optimized = pairwise_metric_optimization(self.metric, elem1, elem2) + + return ( + _pairwise_symmetric(self.metric, elem1, elem2) + if optimized is NotImplemented + else optimized + ) + + def __repr__(self) -> str: + return f"{type(self).__name__}(metric={self.metric})" diff --git a/skfda/misc/operators/__init__.py b/skfda/misc/operators/__init__.py index 62d78e994..7cac49f18 100644 --- a/skfda/misc/operators/__init__.py +++ b/skfda/misc/operators/__init__.py @@ -1,6 +1,10 @@ +"""Operators applicable to functional data.""" from ._identity import Identity from ._integral_transform import IntegralTransform from ._linear_differential_operator import LinearDifferentialOperator -from ._operators import (Operator, gramian_matrix, - gramian_matrix_optimization, - MatrixOperator) +from ._operators import ( + MatrixOperator, + Operator, + gramian_matrix, + gramian_matrix_optimization, +) diff --git a/skfda/misc/operators/_identity.py b/skfda/misc/operators/_identity.py index 16067002e..4ef00b66e 100644 --- a/skfda/misc/operators/_identity.py +++ b/skfda/misc/operators/_identity.py @@ -1,11 +1,18 @@ +from __future__ import annotations + +from typing import Any, TypeVar + import numpy as np from ...representation import FDataGrid +from ...representation._typing import Vector from ...representation.basis import Basis from ._operators import Operator, gramian_matrix_optimization +T = TypeVar("T", bound=Vector) -class Identity(Operator): + +class Identity(Operator[T, T]): """Identity operator. Linear operator that returns its input. @@ -17,22 +24,25 @@ class Identity(Operator): """ - def __call__(self, f): + def __call__(self, f: T) -> T: # noqa: D102 return f @gramian_matrix_optimization.register def basis_penalty_matrix_optimized( - linear_operator: Identity, - basis: Basis): - + linear_operator: Identity[Any], + basis: Basis, +) -> np.ndarray: + """Optimized version of the penalty matrix for Basis.""" return basis.gram_matrix() @gramian_matrix_optimization.register def fdatagrid_penalty_matrix_optimized( - linear_operator: Identity, - basis: FDataGrid): - from ..metrics import lp_norm + linear_operator: Identity[Any], + basis: FDataGrid, +) -> np.ndarray: + """Optimized version of the penalty matrix for FDataGrid.""" + from ..metrics import l2_norm - return np.diag(lp_norm(basis)**2) + return np.diag(l2_norm(basis)**2) diff --git a/skfda/misc/operators/_integral_transform.py b/skfda/misc/operators/_integral_transform.py index aab01d5ad..943718de8 100644 --- a/skfda/misc/operators/_integral_transform.py +++ b/skfda/misc/operators/_integral_transform.py @@ -1,33 +1,52 @@ +from __future__ import annotations + +from typing import Callable + +import numpy as np + import scipy.integrate +from ...representation import FData from ._operators import Operator -class IntegralTransform(Operator): +class IntegralTransform(Operator[FData, Callable[[np.ndarray], np.ndarray]]): """Integral operator. - - - Attributes: - kernel_function (callable): Kernel function corresponding to - the operator. + Parameters: + kernel_function: Kernel function corresponding to the operator. """ - def __init__(self, kernel_function): + def __init__( + self, + kernel_function: Callable[[np.ndarray, np.ndarray], np.ndarray], + ) -> None: self.kernel_function = kernel_function - def __call__(self, f): + def __call__( # noqa: D102 + self, + f: FData, + ) -> Callable[[np.ndarray], np.ndarray]: - def evaluate_covariance(points): + def evaluate_covariance( # noqa: WPS430 + points: np.ndarray, + ) -> np.ndarray: - def integral_body(integration_var): - return (f(integration_var) * - self.kernel_function(integration_var, points)) + def integral_body( # noqa: WPS430 + integration_var: np.ndarray, + ) -> np.ndarray: + return ( + f(integration_var) + * self.kernel_function(integration_var, points) + ) domain_range = f.domain_range[0] return scipy.integrate.quad_vec( - integral_body, domain_range[0], domain_range[1])[0] + integral_body, + domain_range[0], + domain_range[1], + )[0] return evaluate_covariance diff --git a/skfda/misc/operators/_linear_differential_operator.py b/skfda/misc/operators/_linear_differential_operator.py index 2597b6740..aaef7c1e5 100644 --- a/skfda/misc/operators/_linear_differential_operator.py +++ b/skfda/misc/operators/_linear_differential_operator.py @@ -1,35 +1,71 @@ +from __future__ import annotations + import numbers +from typing import Callable, Optional, Sequence, Tuple, Union, cast import numpy as np -import scipy.integrate from numpy import polyder, polyint, polymul, polyval + +import scipy.integrate from scipy.interpolate import PPoly -from ..._utils import _FDataCallable, _same_domain -from ...representation import FDataGrid -from ...representation.basis import BSpline, Constant, Fourier, Monomial +from ..._utils import _same_domain +from ...representation import FData, FDataGrid +from ...representation._typing import DomainRangeLike +from ...representation.basis import ( + BSpline, + Constant, + FDataBasis, + Fourier, + Monomial, +) from ._operators import Operator, gramian_matrix_optimization -__author__ = "Pablo Pérez Manso" -__email__ = "92manso@gmail.com" +Order = int + +WeightSequence = Sequence[ + Union[ + float, + Callable[[np.ndarray], np.ndarray], + ], +] -class LinearDifferentialOperator(Operator): - """Defines the structure of a linear differential operator function system +class LinearDifferentialOperator( + Operator[FData, Callable[[np.ndarray], np.ndarray]], +): + r""" + Defines the structure of a linear differential operator function system. .. math:: Lx(t) = b_0(t) x(t) + b_1(t) x'(x) + - \\dots + b_{n-1}(t) d^{n-1}(x(t)) + b_n(t) d^n(x(t)) + \dots + b_{n-1}(t) d^{n-1}(x(t)) + b_n(t) d^n(x(t)) Can only be applied to functional data, as multivariate data has no derivatives. - Attributes: + You have to provide either order or weights. + If both are provided, it will raise an error. + If a positional argument is supplied it will be considered the + order if it is an integral type and the weights otherwise. + + Parameters: + order (int, optional): the order of the operator. It's the highest + derivative order of the operator + + weights (list, optional): A FDataBasis objects list of length + order + 1 items + domain_range (tuple or list of tuples, optional): Definition + of the interval where the weight functions are + defined. If the functional weights are specified + and this is not, takes the domain range from them. + Otherwise, defaults to (0,1). + + Attributes: weights (list): A list of callables. Examples: - Create a linear differential operator that penalizes the second derivative (acceleration) @@ -39,19 +75,7 @@ class LinearDifferentialOperator(Operator): >>> >>> LinearDifferentialOperator(2) LinearDifferentialOperator( - weights=[ - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 0.]], - ...), - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 0.]], - ...), - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 1.]], - ...)] + weights=(0, 0, 1), ) Create a linear differential operator that penalizes three times @@ -59,19 +83,7 @@ class LinearDifferentialOperator(Operator): >>> LinearDifferentialOperator(weights=[0, 2, 3]) LinearDifferentialOperator( - weights=[ - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 0.]], - ...), - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 2.]], - ...), - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 3.]], - ...)] + weights=(0, 2, 3), ) Create a linear differential operator with non-constant weights. @@ -83,124 +95,95 @@ class LinearDifferentialOperator(Operator): ... FDataBasis(monomial, [1., 2., 3.])] >>> LinearDifferentialOperator(weights=fdlist) LinearDifferentialOperator( - weights=[ - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 0.]], - ...), - FDataBasis( - basis=Constant(domain_range=((0, 1),), n_basis=1), - coefficients=[[ 0.]], - ...), - FDataBasis( - basis=Monomial(domain_range=((0, 1),), n_basis=3), - coefficients=[[ 1. 2. 3.]], - ...)] + weights=(FDataBasis( + basis=Constant(domain_range=((0, 1),), n_basis=1), + coefficients=[[ 0.]], + ...), + FDataBasis( + basis=Constant(domain_range=((0, 1),), n_basis=1), + coefficients=[[ 0.]], + ...), + FDataBasis( + basis=Monomial(domain_range=((0, 1),), n_basis=3), + coefficients=[[ 1. 2. 3.]], + ...)), ) """ def __init__( - self, order_or_weights=None, *, order=None, weights=None, - domain_range=None): - """Constructor. You have to provide either order or weights. - If both are provided, it will raise an error. - If a positional argument is supplied it will be considered the - order if it is an integral type and the weights otherwise. - - Args: - order (int, optional): the order of the operator. It's the highest - derivative order of the operator - - weights (list, optional): A FDataBasis objects list of length - order + 1 items - - domain_range (tuple or list of tuples, optional): Definition - of the interval where the weight functions are - defined. If the functional weights are specified - and this is not, takes the domain range from them. - Otherwise, defaults to (0,1). - - """ - - from ...representation.basis import FDataBasis + self, + order_or_weights: Union[Order, WeightSequence, None] = None, + *, + order: Optional[int] = None, + weights: Optional[WeightSequence] = None, + domain_range: Optional[DomainRangeLike] = None, + ) -> None: num_args = sum( - [a is not None for a in [order_or_weights, order, weights]]) + a is not None for a in (order_or_weights, order, weights) + ) if num_args > 1: - raise ValueError("You have to provide the order or the weights, " - "not both") - - real_domain_range = (domain_range if domain_range is not None - else (0, 1)) + raise ValueError( + "You have to provide the order or the weights, not both.", + ) if order_or_weights is not None: if isinstance(order_or_weights, numbers.Integral): - order = order_or_weights + order = int(order_or_weights) else: + assert isinstance(order_or_weights, Sequence) weights = order_or_weights if order is None and weights is None: - self.weights = (FDataBasis(Constant(real_domain_range), 0),) + weights = (0,) - elif weights is None: + elif order is not None: if order < 0: - raise ValueError("Order should be an non-negative integer") - - self.weights = [ - FDataBasis(Constant(real_domain_range), - 0 if (i < order) else 1) - for i in range(order + 1)] - - else: - if len(weights) == 0: - raise ValueError("You have to provide one weight at least") - - if all(isinstance(n, numbers.Real) for n in weights): - self.weights = list(FDataBasis(Constant(real_domain_range), - np.array(weights) - .reshape(-1, 1))) - - elif all(isinstance(n, FDataBasis) for n in weights): - if all([_same_domain(weights[0], x) - and x.n_samples == 1 for x in weights]): - self.weights = weights - - real_domain_range = weights[0].domain_range - if (domain_range is not None - and real_domain_range != domain_range): - raise ValueError("The domain range provided for the " - "linear operator does not match the " - "domain range of the weights") - - else: - raise ValueError("FDataBasis objects in the list have " - "not the same domain_range") - - else: - raise ValueError("The elements of the list are neither " - "integers or FDataBasis objects") - - self.domain_range = real_domain_range - - def __repr__(self): - """Representation of linear differential operator object.""" - - bwtliststr = "" - for w in self.weights: - bwtliststr = bwtliststr + "\n" + repr(w) + "," - - return (f"{self.__class__.__name__}(" - f"\nweights=[{bwtliststr[:-1]}]" - f"\n)").replace('\n', '\n ') - - def __eq__(self, other): - """Equality of linear differential operator objects""" - return (self.weights == other.weights) + raise ValueError("Order should be an non-negative integer.") + + weights = tuple( + 0 if (i < order) else 1 + for i in range(order + 1) + ) + + assert weights is not None + if len(weights) == 0: + raise ValueError("You have to provide one weight at least.") + + # Check domain ranges + for w in weights: + w_domain_range = getattr(w, "domain_range", None) + + if w_domain_range is not None: + if domain_range is None: + domain_range = w_domain_range + elif not np.array_equal(w_domain_range, domain_range): + raise ValueError( + "Weights with wrong domain range.", + ) + self.weights = tuple(weights) + + def __repr__(self) -> str: + + return ( + f"{self.__class__.__name__}(\n" + f"\tweights={self.weights},\n" + f")" + ).replace('\n', '\n ') + + def __eq__(self, other: object) -> bool: + + return ( + isinstance(other, LinearDifferentialOperator) + and self.weights == other.weights + ) - def constant_weights(self): + def constant_weights(self) -> Optional[np.ndarray]: """ + Return constant weights. + Return the scalar weights of the linear differential operator if they are constant basis. Otherwise, return None. @@ -210,25 +193,26 @@ def constant_weights(self): for constant weights. """ - coefs = [w.coefficients[0, 0] if isinstance(w.basis, Constant) - else None - for w in self.weights] + weights = np.array(self.weights) - return np.array(coefs) if coefs.count(None) == 0 else None + return None if weights.dtype == np.object_ else weights - def __call__(self, f): + def __call__(self, f: FData) -> Callable[[np.ndarray], np.ndarray]: """Return the function that results of applying the operator.""" - function_derivatives = [ - f.derivative(order=i) for i, _ in enumerate(self.weights)] + f.derivative(order=i) for i, _ in enumerate(self.weights) + ] - def applied_linear_diff_op(t): - return sum(w(t) * function_derivatives[i](t) - for i, w in enumerate(self.weights)) + def applied_linear_diff_op( + t: np.ndarray, + ) -> np.ndarray: + return sum( + (w(t) if callable(w) else w) + * function_derivatives[i](t) + for i, w in enumerate(self.weights) + ) - return _FDataCallable(applied_linear_diff_op, - domain_range=f.domain_range, - n_samples=len(f)) + return applied_linear_diff_op ############################################################# @@ -240,29 +224,34 @@ def applied_linear_diff_op(t): @gramian_matrix_optimization.register def constant_penalty_matrix_optimized( - linear_operator: LinearDifferentialOperator, - basis: Constant): - + linear_operator: LinearDifferentialOperator, + basis: Constant, +) -> np.ndarray: + """Optimized version for Constant basis.""" coefs = linear_operator.constant_weights() if coefs is None: return NotImplemented - return np.array([[coefs[0] ** 2 * - (basis.domain_range[0][1] - - basis.domain_range[0][0])]]) - + return np.array([[ + coefs[0] ** 2 + * (basis.domain_range[0][1] - basis.domain_range[0][0]), + ]]) -def _monomial_evaluate_constant_linear_diff_op(basis, weights): - """ - Evaluate constant weights of a linear differential operator - over the basis functions. - """ +def _monomial_evaluate_constant_linear_diff_op( + basis: Monomial, + weights: np.ndarray, +) -> np.ndarray: + """Evaluate constant weights over the monomial basis.""" max_derivative = len(weights) - 1 seq = np.arange(basis.n_basis) - coef_mat = np.linspace(seq, seq - max_derivative + 1, - max_derivative, dtype=int) + coef_mat = np.linspace( + seq, + seq - max_derivative + 1, + max_derivative, + dtype=int, + ) # Compute coefficients for each derivative coefs = np.cumprod(coef_mat, axis=0) @@ -286,16 +275,21 @@ def _monomial_evaluate_constant_linear_diff_op(basis, weights): # The matrix is now triangular # refcheck is False to prevent exceptions while debugging weighted_coefs = np.copy(weighted_coefs.T) - weighted_coefs.resize(basis.n_basis, - basis.n_basis, refcheck=False) + weighted_coefs.resize( + basis.n_basis, + basis.n_basis, + refcheck=False, + ) weighted_coefs = weighted_coefs.T # Shift the coefficients so that they correspond to the right # exponent indexes = np.tril_indices(basis.n_basis) polynomials = np.zeros_like(weighted_coefs) - polynomials[indexes[0], indexes[1] - - indexes[0] - 1] = weighted_coefs[indexes] + polynomials[ + indexes[0], + indexes[1] - indexes[0] - 1, + ] = weighted_coefs[indexes] # At this point, each row of the matrix correspond to a polynomial # that is the result of applying the linear differential operator @@ -306,9 +300,10 @@ def _monomial_evaluate_constant_linear_diff_op(basis, weights): @gramian_matrix_optimization.register def monomial_penalty_matrix_optimized( - linear_operator: LinearDifferentialOperator, - basis: Monomial): - + linear_operator: LinearDifferentialOperator, + basis: Monomial, +) -> np.ndarray: + """Optimized version for Monomial basis.""" weights = linear_operator.constant_weights() if weights is None: return NotImplemented @@ -340,10 +335,14 @@ def monomial_penalty_matrix_optimized( integrand /= denom # Add column of zeros at the right to increase exponent - integrand = np.pad(integrand, - pad_width=((0, 0), - (0, 1)), - mode='constant') + integrand = np.pad( + integrand, + pad_width=( + (0, 0), + (0, 1), + ), + mode='constant', + ) # Now, apply Barrow's rule # polyval applies Horner method over the first dimension, @@ -364,19 +363,21 @@ def monomial_penalty_matrix_optimized( return penalty_matrix -def _fourier_penalty_matrix_optimized_orthonormal(basis, weights): - """ - Return the penalty when the basis is orthonormal. - """ - +def _fourier_penalty_matrix_optimized_orthonormal( + basis: Fourier, + weights: np.ndarray, +) -> np.ndarray: + """Return the penalty when the basis is orthonormal.""" signs = np.array([1, 1, -1, -1]) signs_expanded = np.tile(signs, len(weights) // 4 + 1) signs_odd = signs_expanded[:len(weights)] signs_even = signs_expanded[1:len(weights) + 1] - phases = (np.arange(1, (basis.n_basis - 1) // 2 + 1) * - 2 * np.pi / basis.period) + phases = ( + np.arange(1, (basis.n_basis - 1) // 2 + 1) + * 2 * np.pi / basis.period + ) # Compute increasing powers coefs_no_sign = np.vander(phases, len(weights), increasing=True) @@ -411,8 +412,14 @@ def _fourier_penalty_matrix_optimized_orthonormal(basis, weights): penalty_matrix = np.diag(main_diag) # Add row and column for the constant - penalty_matrix = np.pad(penalty_matrix, pad_width=((1, 0), (1, 0)), - mode='constant') + penalty_matrix = np.pad( + penalty_matrix, + pad_width=( + (1, 0), + (1, 0), + ), + mode='constant', + ) penalty_matrix[0, 0] = weights[0]**2 @@ -421,9 +428,10 @@ def _fourier_penalty_matrix_optimized_orthonormal(basis, weights): @gramian_matrix_optimization.register def fourier_penalty_matrix_optimized( - linear_operator: LinearDifferentialOperator, - basis: Fourier): - + linear_operator: LinearDifferentialOperator, + basis: Fourier, +) -> np.ndarray: + """Optimized version for Fourier basis.""" weights = linear_operator.constant_weights() if weights is None: return NotImplemented @@ -438,9 +446,10 @@ def fourier_penalty_matrix_optimized( @gramian_matrix_optimization.register def bspline_penalty_matrix_optimized( - linear_operator: LinearDifferentialOperator, - basis: BSpline): - + linear_operator: LinearDifferentialOperator, + basis: BSpline, +) -> np.ndarray: + """Optimized version for BSpline basis.""" coefs = linear_operator.constant_weights() if coefs is None: return NotImplemented @@ -480,7 +489,7 @@ def bspline_penalty_matrix_optimized( # representation of splines # Places m knots at the boundaries - knots = basis._evaluation_knots() + knots = np.array(basis._evaluation_knots()) # c is used the select which spline the function # PPoly.from_spline below computes @@ -516,10 +525,9 @@ def bspline_penalty_matrix_optimized( # Now for each pair of basis computes the inner product after # applying the linear differential operator penalty_matrix = np.zeros((basis.n_basis, basis.n_basis)) - for interval in range(len(no_0_intervals)): + for interval, _ in enumerate(no_0_intervals): for i in range(basis.n_basis): - poly_i = np.trim_zeros(ppoly_lst[i][:, - interval], 'f') + poly_i = np.trim_zeros(ppoly_lst[i][:, interval], 'f') if len(poly_i) <= derivative_degree: # if the order of the polynomial is lesser or # equal to the derivative the result of the @@ -531,13 +539,16 @@ def bspline_penalty_matrix_optimized( integral = polyint(square) # definite integral - penalty_matrix[i, i] += np.diff(polyval( - integral, basis.knots[interval: interval + 2] - - basis.knots[interval]))[0] + penalty_matrix[i, i] += np.diff( + polyval( + integral, + basis.knots[interval: interval + 2] + - basis.knots[interval], + ), + )[0] for j in range(i + 1, basis.n_basis): - poly_j = np.trim_zeros(ppoly_lst[j][:, - interval], 'f') + poly_j = np.trim_zeros(ppoly_lst[j][:, interval], 'f') if len(poly_j) <= derivative_degree: # if the order of the polynomial is lesser # or equal to the derivative the result of @@ -545,12 +556,18 @@ def bspline_penalty_matrix_optimized( continue # indefinite integral integral = polyint( - polymul(polyder(poly_i, derivative_degree), - polyder(poly_j, derivative_degree))) + polymul( + polyder(poly_i, derivative_degree), + polyder(poly_j, derivative_degree), + ), + ) # definite integral - penalty_matrix[i, j] += np.diff(polyval( - integral, basis.knots[interval: interval + 2] - - basis.knots[interval]) + penalty_matrix[i, j] += np.diff( + polyval( + integral, + basis.knots[interval: interval + 2] + - basis.knots[interval], + ), )[0] penalty_matrix[j, i] = penalty_matrix[i, j] return penalty_matrix @@ -558,13 +575,15 @@ def bspline_penalty_matrix_optimized( @gramian_matrix_optimization.register def fdatagrid_penalty_matrix_optimized( - linear_operator: LinearDifferentialOperator, - basis: FDataGrid): - + linear_operator: LinearDifferentialOperator, + basis: FDataGrid, +) -> np.ndarray: + """Optimized version for FDatagrid.""" evaluated_basis = sum( - w(basis.grid_points[0]) * - basis.derivative(order=i)(basis.grid_points[0]) - for i, w in enumerate(linear_operator.weights)) + w(basis.grid_points[0]) if callable(w) else w + * basis.derivative(order=i)(basis.grid_points[0]) + for i, w in enumerate(linear_operator.weights) + ) indices = np.triu_indices(basis.n_samples) product = evaluated_basis[indices[0]] * evaluated_basis[indices[1]] diff --git a/skfda/misc/operators/_operators.py b/skfda/misc/operators/_operators.py index 7781aaf46..c95e4d32d 100644 --- a/skfda/misc/operators/_operators.py +++ b/skfda/misc/operators/_operators.py @@ -1,22 +1,47 @@ +from __future__ import annotations + import abc +from typing import Any, Callable, TypeVar, Union import multimethod +import numpy as np +from typing_extensions import Protocol +from ...representation import FData +from ...representation.basis import Basis -class Operator(abc.ABC): - """ - Abstract class for :term:`operators`. +OperatorInput = TypeVar( + "OperatorInput", + bound=Union[np.ndarray, FData, Basis], + contravariant=True, +) - """ +OutputType = Union[np.ndarray, Callable[[np.ndarray], np.ndarray]] + +OperatorOutput = TypeVar( + "OperatorOutput", + bound=OutputType, + covariant=True, +) + + +class Operator(Protocol[OperatorInput, OperatorOutput]): + """Abstract class for :term:`operators`.""" @abc.abstractmethod - def __call__(self, vector): + def __call__(self, vector: OperatorInput) -> OperatorOutput: + """Evaluate the operator.""" pass @multimethod.multidispatch -def gramian_matrix_optimization(linear_operator, basis): - r""" +def gramian_matrix_optimization( + linear_operator: Any, + basis: OperatorInput, +) -> np.ndarray: + """ + Efficient implementation of gramian_matrix. + Generic function that can be subclassed for different combinations of operator and basis in order to provide a more efficient implementation for the gramian matrix. @@ -24,8 +49,11 @@ def gramian_matrix_optimization(linear_operator, basis): return NotImplemented -def gramian_matrix_numerical(linear_operator, basis): - r""" +def gramian_matrix_numerical( + linear_operator: Operator[OperatorInput, OutputType], + basis: OperatorInput, +) -> np.ndarray: + """ Return the gramian matrix given a basis, computed numerically. This method should work for every linear operator. @@ -35,10 +63,15 @@ def gramian_matrix_numerical(linear_operator, basis): evaluated_basis = linear_operator(basis) - return inner_product_matrix(evaluated_basis) + domain_range = getattr(basis, "domain_range", None) + + return inner_product_matrix(evaluated_basis, _domain_range=domain_range) -def gramian_matrix(linear_operator, basis): +def gramian_matrix( + linear_operator: Operator[OperatorInput, OutputType], + basis: OperatorInput, +) -> np.ndarray: r""" Return the gramian matrix given a basis. @@ -55,7 +88,6 @@ def gramian_matrix(linear_operator, basis): falling back to a numerical computation otherwise. """ - # Try to use a more efficient implementation matrix = gramian_matrix_optimization(linear_operator, basis) if matrix is not NotImplemented: @@ -64,20 +96,19 @@ def gramian_matrix(linear_operator, basis): return gramian_matrix_numerical(linear_operator, basis) -class MatrixOperator(Operator): +class MatrixOperator(Operator[np.ndarray, np.ndarray]): """Linear operator for finite spaces. Between finite dimensional spaces, every linear operator can be expressed as a product by a matrix. Attributes: - matrix (array-like object): The matrix containing the linear - transformation. + matrix: The matrix containing the linear transformation. """ - def __init__(self, matrix): + def __init__(self, matrix: np.ndarray) -> None: self.matrix = matrix - def __call__(self, f): + def __call__(self, f: np.ndarray) -> np.ndarray: # noqa: D102 return self.matrix @ f diff --git a/skfda/misc/regularization/__init__.py b/skfda/misc/regularization/__init__.py index 01f89d797..769366cff 100644 --- a/skfda/misc/regularization/__init__.py +++ b/skfda/misc/regularization/__init__.py @@ -1,3 +1,5 @@ -from ._regularization import (TikhonovRegularization, - L2Regularization, - compute_penalty_matrix) +from ._regularization import ( + L2Regularization, + TikhonovRegularization, + compute_penalty_matrix, +) diff --git a/skfda/misc/regularization/_regularization.py b/skfda/misc/regularization/_regularization.py index 42a496ac6..ea920c583 100644 --- a/skfda/misc/regularization/_regularization.py +++ b/skfda/misc/regularization/_regularization.py @@ -1,14 +1,24 @@ -from collections.abc import Iterable +from __future__ import annotations + import itertools -from skfda.misc.operators import gramian_matrix, Identity +from typing import Any, Generic, Iterable, Optional, Union -import scipy.linalg +import numpy as np from sklearn.base import BaseEstimator -import numpy as np +import scipy.linalg +from skfda.misc.operators import Identity, gramian_matrix +from ...representation import FData +from ...representation.basis import Basis +from ..operators import Operator +from ..operators._operators import OperatorInput -class TikhonovRegularization(BaseEstimator): + +class TikhonovRegularization( + BaseEstimator, # type: ignore + Generic[OperatorInput], +): r""" Implements Tikhonov regularization. @@ -33,7 +43,6 @@ class TikhonovRegularization(BaseEstimator): penalization. Examples: - Construct a regularization that penalizes the second derivative, which is a measure of the curvature of the function. @@ -77,21 +86,29 @@ class TikhonovRegularization(BaseEstimator): """ - def __init__(self, linear_operator, - *, regularization_parameter=1): + def __init__( + self, + linear_operator: Operator[OperatorInput, Any], + *, + regularization_parameter: float = 1, + ) -> None: self.linear_operator = linear_operator self.regularization_parameter = regularization_parameter - def penalty_matrix(self, basis): - r""" - Return a penalty matrix for ordinary least squares. - - """ + def penalty_matrix( + self, + basis: OperatorInput, + ) -> np.ndarray: + """Return a penalty matrix for ordinary least squares.""" return self.regularization_parameter * gramian_matrix( - self.linear_operator, basis) + self.linear_operator, + basis, + ) -class L2Regularization(TikhonovRegularization): +class L2Regularization( + TikhonovRegularization[Union[np.ndarray, FData, Basis]], +): r""" Implements :math:`L_2` regularization. @@ -113,23 +130,40 @@ class L2Regularization(TikhonovRegularization): """ - def __init__(self, *, regularization_parameter=1): + def __init__( + self, + *, + regularization_parameter: float = 1, + ) -> None: return super().__init__( linear_operator=Identity(), - regularization_parameter=regularization_parameter) + regularization_parameter=regularization_parameter, + ) + + +BasisTypes = Union[np.ndarray, FData, Basis] +Regularization = TikhonovRegularization[Any] +RegularizationLike = Union[ + None, + Regularization, + Iterable[Optional[Regularization]], +] -def compute_penalty_matrix(basis_iterable, regularization_parameter, - regularization): +def compute_penalty_matrix( + basis_iterable: Iterable[BasisTypes], + regularization_parameter: Union[float, Iterable[float]], + regularization: RegularizationLike, +) -> Optional[np.ndarray]: """ - Computes the regularization matrix for a linear differential operator. + Compute the regularization matrix for a linear differential operator. X can be a list of mixed data. """ # If there is no regularization, return 0 and rely on broadcasting if regularization_parameter == 0 or regularization is None: - return 0 + return None # Compute penalty matrix if not provided if not isinstance(regularization, Iterable): @@ -137,13 +171,16 @@ def compute_penalty_matrix(basis_iterable, regularization_parameter, if not isinstance(regularization_parameter, Iterable): regularization_parameter = itertools.repeat( - regularization_parameter) + regularization_parameter, + ) penalty_blocks = [ np.zeros((len(b), len(b))) if r is None else a * r.penalty_matrix(b) - for b, r, a in zip(basis_iterable, regularization, - regularization_parameter)] - penalty_matrix = scipy.linalg.block_diag(*penalty_blocks) + for b, r, a in zip( + basis_iterable, + regularization, + regularization_parameter, + )] - return penalty_matrix + return scipy.linalg.block_diag(*penalty_blocks) diff --git a/skfda/ml/_neighbors_base.py b/skfda/ml/_neighbors_base.py index ac7b02662..5d2ffebd0 100644 --- a/skfda/ml/_neighbors_base.py +++ b/skfda/ml/_neighbors_base.py @@ -2,14 +2,12 @@ from abc import ABC -from sklearn.base import BaseEstimator -from sklearn.base import RegressorMixin -from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted - import numpy as np +from sklearn.base import BaseEstimator, RegressorMixin +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted -from .. import FDataGrid, FData -from ..misc.metrics import lp_distance +from .. import FData, FDataGrid +from ..misc.metrics import l2_distance def _to_multivariate(fdatagrid): @@ -63,7 +61,7 @@ def _to_multivariate_metric(metric, grid_points): >>> import numpy as np >>> from skfda import FDataGrid - >>> from skfda.misc.metrics import lp_distance + >>> from skfda.misc.metrics import l2_distance >>> from skfda.ml._neighbors_base import _to_multivariate_metric Calculate the Lp distance between fd and fd2. @@ -71,24 +69,24 @@ def _to_multivariate_metric(metric, grid_points): >>> x = np.linspace(0, 1, 101) >>> fd = FDataGrid([np.ones(len(x))], x) >>> fd2 = FDataGrid([np.zeros(len(x))], x) - >>> lp_distance(fd, fd2).round(2) + >>> l2_distance(fd, fd2).round(2) array([ 1.]) Creation of the sklearn-style metric. - >>> sklearn_lp_distance = _to_multivariate_metric(lp_distance, [x]) - >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) + >>> sklearn_l2_distance = _to_multivariate_metric(l2_distance, [x]) + >>> sklearn_l2_distance(np.ones(len(x)), np.zeros(len(x))).round(2) array([ 1.]) """ # Shape -> (n_samples = 1, domain_dims...., image_dimension (-1)) shape = [1] + [len(axis) for axis in grid_points] + [-1] - def multivariate_metric(x, y, _check=False, **kwargs): + def multivariate_metric(x, y, **kwargs): return metric(_from_multivariate(x, grid_points, shape), _from_multivariate(y, grid_points, shape), - _check=_check, **kwargs) + **kwargs) return multivariate_metric @@ -162,7 +160,7 @@ def fit(self, X, y=None): if not self.multivariate_metric: # Constructs sklearn metric to manage vector if self.metric == 'l2': - metric = lp_distance + metric = l2_distance else: metric = self.metric @@ -499,7 +497,7 @@ def _functional_fit(self, X, y): if not self.multivariate_metric: if self.metric == 'l2': - metric = lp_distance + metric = l2_distance else: metric = self.metric diff --git a/skfda/ml/classification/__init__.py b/skfda/ml/classification/__init__.py index da2bca618..2e5689d8b 100644 --- a/skfda/ml/classification/__init__.py +++ b/skfda/ml/classification/__init__.py @@ -1,6 +1,10 @@ """Classification.""" from ._centroid_classifiers import DTMClassifier, NearestCentroid -from ._depth_classifiers import MaximumDepthClassifier +from ._depth_classifiers import ( + DDClassifier, + DDGClassifier, + MaximumDepthClassifier, +) from ._neighbors_classifiers import ( KNeighborsClassifier, RadiusNeighborsClassifier, diff --git a/skfda/ml/classification/_centroid_classifiers.py b/skfda/ml/classification/_centroid_classifiers.py index 8b36fc001..33a7c9b38 100644 --- a/skfda/ml/classification/_centroid_classifiers.py +++ b/skfda/ml/classification/_centroid_classifiers.py @@ -1,30 +1,37 @@ """Centroid-based models for supervised classification.""" +from __future__ import annotations -from typing import Callable +from typing import Callable, Generic, Optional, TypeVar +from numpy import ndarray from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted from ..._utils import _classifier_get_classes from ...exploratory.depth import Depth, ModifiedBandDepth from ...exploratory.stats import mean, trim_mean -from ...misc.metrics import l2_distance, lp_distance, pairwise_distance +from ...misc.metrics import Metric, PairwiseMetric, l2_distance +from ...representation import FData +T = TypeVar("T", bound=FData) -class NearestCentroid(BaseEstimator, ClassifierMixin): + +class NearestCentroid( + BaseEstimator, # type: ignore + ClassifierMixin, # type: ignore + Generic[T], +): """Nearest centroid classifier for functional data. Each class is represented by its centroid, with test samples classified to the class with the nearest centroid. Parameters: - metric: callable, (default - :func:`lp_distance `) + metric: The metric to use when calculating distance between test samples and centroids. See the documentation of the metrics module - for a list of available metrics. Defaults used L2 distance. - centroid: callable, (default - :func:`mean `) + for a list of available metrics. L2 distance is used by default. + centroid: The centroids for the samples corresponding to each class is the point from which the sum of the distances (according to the metric) of all samples that belong to that particular class are minimized. @@ -59,73 +66,80 @@ class and return a :class:`FData` object with only one sample :class:`~skfda.ml.classification.DTMClassifier` """ - def __init__(self, metric=l2_distance, centroid=mean): + def __init__( + self, + metric: Metric[T] = l2_distance, + centroid: Callable[[T], T] = mean, + ): self.metric = metric self.centroid = centroid - def fit(self, X, y): + def fit(self, X: T, y: ndarray) -> NearestCentroid[T]: """Fit the model using X as training data and y as target values. Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (array-like or sparse matrix): Target values of - shape = [n_samples] or [n_samples, n_outputs]. + X: FDataGrid with the training data or array matrix with shape + (n_samples, n_samples) if metric='precomputed'. + y: Target values of + shape = (n_samples) or (n_samples, n_outputs). Returns: - self (object) + self """ - classes_, y_ind = _classifier_get_classes(y) + classes, y_ind = _classifier_get_classes(y) - self.classes_ = classes_ + self._classes = classes self.centroids_ = self.centroid(X[y_ind == 0]) - for cur_class in range(1, self.classes_.size): + for cur_class in range(1, self._classes.size): centroid = self.centroid(X[y_ind == cur_class]) self.centroids_ = self.centroids_.concatenate(centroid) return self - def predict(self, X): + def predict(self, X: T) -> ndarray: """Predict the class labels for the provided data. Args: - X (:class:`FDataGrid`): FDataGrid with the test samples. + X: FDataGrid with the test samples. Returns: - y (np.array): array of shape [n_samples] or - [n_samples, n_outputs] with class labels for each data sample. + Array of shape (n_samples) or + (n_samples, n_outputs) with class labels for each data sample. """ sklearn_check_is_fitted(self) - return self.classes_[pairwise_distance(self.metric)( + return self._classes[PairwiseMetric(self.metric)( X, self.centroids_, ).argmin(axis=1) ] -class DTMClassifier(BaseEstimator, ClassifierMixin): +class DTMClassifier( + BaseEstimator, # type: ignore + ClassifierMixin, # type: ignore + Generic[T], +): """Distance to trimmed means (DTM) classification. Test samples are classified to the class that minimizes the distance of - the observation to the trimmed mean of the group. + the observation to the trimmed mean of the group + :footcite:`fraiman+muniz_2001_trimmed`. Parameters: - proportiontocut (float): indicates the percentage of functions to - remove. It is not easy to determine as it varies from dataset to + proportiontocut: + Indicates the percentage of functions to remove. + It is not easy to determine as it varies from dataset to dataset. - depth_method (Depth, default - :class:`ModifiedBandDepth `): + depth_method: The depth class used to order the data. See the documentation of the depths module for a list of available depths. By default it is ModifiedBandDepth. - metric (Callable, default - :func:`lp_distance `): + metric: Distance function between two functional objects. See the documentation of the metrics module for a list of available - metrics. + metrics. L2 distance is used by default. Examples: Firstly, we will import and split the Berkeley Growth Study dataset @@ -157,38 +171,36 @@ class DTMClassifier(BaseEstimator, ClassifierMixin): 0.875 See also: - :class:`~skfda.ml.classification.MaximumDepthClassifier` + :class:`~skfda.ml.classification.NearestCentroid` References: - Fraiman, R. and Muniz, G. (2001). Trimmed means for functional - data. Test, 10, 419-440. + .. footbibliography:: + """ def __init__( self, proportiontocut: float, - depth_method: Depth = None, - metric: Callable = lp_distance, + depth_method: Optional[Depth[T]] = None, + metric: Metric[T] = l2_distance, ) -> None: self.proportiontocut = proportiontocut - - if depth_method is None: - self.depth_method = ModifiedBandDepth() - else: - self.depth_method = depth_method - + self.depth_method = depth_method self.metric = metric - def fit(self, X, y): + def fit(self, X: T, y: ndarray) -> DTMClassifier[T]: """Fit the model using X as training data and y as target values. Args: - X (:class:`FDataGrid`): FDataGrid with the training data. - y (array-like): Target values of shape = [n_samples]. + X: FDataGrid with the training data. + y: Target values of shape = (n_samples). Returns: - self (object) + self """ + if self.depth_method is None: + self.depth_method = ModifiedBandDepth() + self._clf = NearestCentroid( metric=self.metric, centroid=lambda fdatagrid: trim_mean( @@ -201,14 +213,14 @@ def fit(self, X, y): return self - def predict(self, X): + def predict(self, X: T) -> ndarray: """Predict the class labels for the provided data. Args: - X (:class:`FDataGrid`): FDataGrid with the test samples. + X: FDataGrid with the test samples. Returns: - y (np.array): array of shape [n_samples] or - [n_samples, n_outputs] with class labels for each data sample. + Array of shape (n_samples) or + (n_samples, n_outputs) with class labels for each data sample. """ return self._clf.predict(X) diff --git a/skfda/ml/classification/_depth_classifiers.py b/skfda/ml/classification/_depth_classifiers.py index 37aa27f42..acd85adf6 100644 --- a/skfda/ml/classification/_depth_classifiers.py +++ b/skfda/ml/classification/_depth_classifiers.py @@ -1,21 +1,36 @@ """Depth-based models for supervised classification.""" +from __future__ import annotations + +from itertools import combinations +from typing import Generic, Optional, Sequence, TypeVar, Union import numpy as np +from numpy import ndarray +from scipy.interpolate import lagrange from sklearn.base import BaseEstimator, ClassifierMixin, clone +from sklearn.metrics import accuracy_score +from sklearn.pipeline import make_pipeline from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted -from ..._utils import _classifier_get_classes +from ..._utils import _classifier_fit_depth_methods from ...exploratory.depth import Depth, ModifiedBandDepth +from ...preprocessing.dim_reduction.feature_extraction import DDGTransformer +from ...representation.grid import FData + +T = TypeVar("T", bound=FData) -class MaximumDepthClassifier(BaseEstimator, ClassifierMixin): +class MaximumDepthClassifier( + BaseEstimator, # type: ignore + ClassifierMixin, # type: ignore + Generic[T], +): """Maximum depth classifier for functional data. Test samples are classified to the class where they are deeper. Parameters: - depth_method (Depth, default - :class:`ModifiedBandDepth `): + depth_method: The depth class to use when calculating the depth of a test sample in a class. See the documentation of the depths module for a list of available depths. By default it is ModifiedBandDepth. @@ -41,7 +56,7 @@ class MaximumDepthClassifier(BaseEstimator, ClassifierMixin): >>> clf.predict(X_test) # Predict labels for test samples array([1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, - 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1]) + 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1]) Finally, we calculate the mean accuracy for the test data @@ -49,58 +64,325 @@ class MaximumDepthClassifier(BaseEstimator, ClassifierMixin): 0.875 See also: - :class:`~skfda.ml.classification.DTMClassifier` + :class:`~skfda.ml.classification.DDClassifier` + :class:`~skfda.ml.classification.DDGClassifier` References: Ghosh, A. K. and Chaudhuri, P. (2005b). On maximum depth and related classifiers. Scandinavian Journal of Statistics, 32, 327–350. """ - def __init__(self, depth_method: Depth = None): + def __init__(self, depth_method: Optional[Depth[T]] = None) -> None: self.depth_method = depth_method - if depth_method is None: + def fit(self, X: T, y: ndarray) -> MaximumDepthClassifier[T]: + """Fit the model using X as training data and y as target values. + + Args: + X: FDataGrid with the training data. + y: Target values of shape = (n_samples). + + Returns: + self + """ + if self.depth_method is None: self.depth_method = ModifiedBandDepth() - else: - self.depth_method = depth_method - def fit(self, X, y): - """Fit the model using X as training data and y as target values. + classes, class_depth_methods = _classifier_fit_depth_methods( + X, y, [self.depth_method], + ) + + self._classes = classes + self.class_depth_methods_ = class_depth_methods + + return self + + def predict(self, X: T) -> ndarray: + """Predict the class labels for the provided data. Args: - X (:class:`FDataGrid`): FDataGrid with the training data. - y (array-like): Target values of shape = [n_samples]. + X: FDataGrid with the test samples. Returns: - self (object) + Array of shape (n_samples) with class labels + for each data sample. + """ + sklearn_check_is_fitted(self) + + depths = [ + depth_method.predict(X) + for depth_method in self.class_depth_methods_ + ] + + return self._classes[np.argmax(depths, axis=0)] + + +class DDClassifier( + BaseEstimator, # type: ignore + ClassifierMixin, # type: ignore + Generic[T], +): + """Depth-versus-depth (DD) classifer for functional data. + + Transforms the data into a DD-plot and then classifies using a polynomial + of a chosen degree. The polynomial passes through zero and maximizes the + accuracy of the classification on the train dataset. + + If a point is below the polynomial in the DD-plot, it is classified to + the first class. Otherwise, the point is classified to the second class. + + Parameters: + degree: degree of the polynomial used to classify in the DD-plot + depth_method: + The depth class to use when calculating the depth of a test + sample in a class. See the documentation of the depths module + for a list of available depths. By default it is ModifiedBandDepth. + + Examples: + Firstly, we will import and split the Berkeley Growth Study dataset + + >>> from skfda.datasets import fetch_growth + >>> from sklearn.model_selection import train_test_split + >>> dataset = fetch_growth() + >>> fd = dataset['data'] + >>> y = dataset['target'] + >>> X_train, X_test, y_train, y_test = train_test_split( + ... fd, y, test_size=0.25, stratify=y, random_state=0) + + We will fit a DD-classifier + + >>> from skfda.ml.classification import DDClassifier + >>> clf = DDClassifier(degree=2) + >>> clf.fit(X_train, y_train) + DDClassifier(...) + + We can predict the class of new samples + + >>> clf.predict(X_test) + array([1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1]) + + Finally, we calculate the mean accuracy for the test data + + >>> clf.score(X_test, y_test) + 0.875 + See also: + :class:`~skfda.ml.classification.DDGClassifier` + :class:`~skfda.ml.classification.MaximumDepthClassifier` + :class:`~skfda.preprocessing.dim_reduction.feature_extraction._ddg_transformer` + + References: + Li, J., Cuesta-Albertos, J. A., and Liu, R. Y. (2012). DD-classifier: + Nonparametric classification procedure based on DD-plot. Journal of + the American Statistical Association, 107(498):737-753. + """ + + def __init__( + self, + degree: int, + depth_method: Optional[Depth[T]] = None, + ) -> None: + self.depth_method = depth_method + self.degree = degree + + def fit(self, X: T, y: ndarray) -> DDClassifier[T]: + """Fit the model using X as training data and y as target values. + + Args: + X: FDataGrid with the training data. + y: Target values of shape = (n_samples). + + Returns: + self """ - classes_, y_ind = _classifier_get_classes(y) + if self.depth_method is None: + self.depth_method = ModifiedBandDepth() - self.classes_ = classes_ - self.distributions_ = [ - clone(self.depth_method).fit(X[y_ind == cur_class]) - for cur_class in range(self.classes_.size) + classes, class_depth_methods = _classifier_fit_depth_methods( + X, y, [self.depth_method], + ) + + self._classes = classes + self.class_depth_methods_ = class_depth_methods + + if (len(self._classes) != 2): + raise ValueError("DDClassifier only accepts two classes.") + + dd_coordinates = [ + depth_method.predict(X) + for depth_method in self.class_depth_methods_ ] + polynomial_elements = combinations( + range(len(dd_coordinates[0])), # noqa: WPS518 + self.degree, + ) + + accuracy = -1 # initialise accuracy + + for elements in polynomial_elements: + x_coord = np.append(dd_coordinates[0][list(elements)], 0) + y_coord = np.append(dd_coordinates[1][list(elements)], 0) + + poly = lagrange( + x_coord, y_coord, + ) + + predicted_values = np.polyval(poly, dd_coordinates[0]) + + y_pred = self._classes[( + dd_coordinates[1] > predicted_values + ).astype(int) + ] + + new_accuracy = accuracy_score(y, y_pred) + + if (new_accuracy > accuracy): + accuracy = new_accuracy + self.poly_ = poly + return self - def predict(self, X): + def predict(self, X: T) -> ndarray: """Predict the class labels for the provided data. Args: - X (:class:`FDataGrid`): FDataGrid with the test samples. + X: FDataGrid with the test samples. Returns: - y (np.array): array of shape [n_samples] with class labels + Array of shape (n_samples) with class labels for each data sample. - """ sklearn_check_is_fitted(self) - depths = [ - distribution.predict(X) - for distribution in self.distributions_ + dd_coordinates = [ + depth_method.predict(X) + for depth_method in self.class_depth_methods_ ] - return self.classes_[np.argmax(depths, axis=0)] + predicted_values = np.polyval(self.poly_, dd_coordinates[0]) + + return self._classes[( + dd_coordinates[1] > predicted_values + ).astype(int) + ] + + +class DDGClassifier( + BaseEstimator, # type: ignore + ClassifierMixin, # type: ignore + Generic[T], +): + r"""Generalized depth-versus-depth (DD) classifer for functional data. + + This classifier builds an interface around the DDGTransfomer. + + The transformer takes a list of k depths and performs the following map: + + .. math:: + \mathcal{X} &\rightarrow \mathbb{R}^G \\ + x &\rightarrow \textbf{d} = (D_1^1(x), D_1^2(x),...,D_g^k(x)) + + Where :math:`D_i^j(x)` is the depth of the point :math:`x` with respect to + the data in the :math:`i`-th group using the :math:`j`-th depth of the + provided list. + + Note that :math:`\mathcal{X}` is possibly multivariate, that is, + :math:`\mathcal{X} = \mathcal{X}_1 \times ... \times \mathcal{X}_p`. + + In the G dimensional space the classification is performed using a + multivariate classifer. + + Parameters: + depth_method: + The depth class or sequence of depths to use when calculating + the depth of a test sample in a class. See the documentation of + the depths module for a list of available depths. By default it + is ModifiedBandDepth. + multivariate_classifier: + The multivariate classifier to use in the DDG-plot. + + Examples: + Firstly, we will import and split the Berkeley Growth Study dataset + + >>> from skfda.datasets import fetch_growth + >>> from sklearn.model_selection import train_test_split + >>> dataset = fetch_growth() + >>> fd = dataset['data'] + >>> y = dataset['target'] + >>> X_train, X_test, y_train, y_test = train_test_split( + ... fd, y, test_size=0.25, stratify=y, random_state=0) + + >>> from sklearn.neighbors import KNeighborsClassifier + + We will fit a DDG-classifier using KNN + + >>> from skfda.ml.classification import DDGClassifier + >>> clf = DDGClassifier(KNeighborsClassifier()) + >>> clf.fit(X_train, y_train) + DDGClassifier(...) + + We can predict the class of new samples + + >>> clf.predict(X_test) + array([1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1]) + + Finally, we calculate the mean accuracy for the test data + + >>> clf.score(X_test, y_test) + 0.875 + + See also: + :class:`~skfda.ml.classification.DDClassifier` + :class:`~skfda.ml.classification.MaximumDepthClassifier` + :class:`~skfda.preprocessing.dim_reduction.feature_extraction._ddg_transformer` + + References: + Li, J., Cuesta-Albertos, J. A., and Liu, R. Y. (2012). DD-classifier: + Nonparametric classification procedure based on DD-plot. Journal of + the American Statistical Association, 107(498):737-753. + + Cuesta-Albertos, J.A., Febrero-Bande, M. and Oviedo de la Fuente, M. + (2017) The DDG-classifier in the functional setting. TEST, 26. 119-142. + """ + + def __init__( + self, + multivariate_classifier: ClassifierMixin = None, + depth_method: Union[Depth[T], Sequence[Depth[T]], None] = None, + ) -> None: + self.multivariate_classifier = multivariate_classifier + self.depth_method = depth_method + + def fit(self, X: T, y: ndarray) -> DDGClassifier[T]: + """Fit the model using X as training data and y as target values. + + Args: + X: FDataGrid with the training data. + y: Target values of shape = (n_samples). + + Returns: + self + """ + self._pipeline = make_pipeline( + DDGTransformer(self.depth_method), + clone(self.multivariate_classifier), + ) + + self._pipeline.fit(X, y) + + return self + + def predict(self, X: T) -> ndarray: + """Predict the class labels for the provided data. + + Args: + X: FDataGrid with the test samples. + + Returns: + Array of shape (n_samples) with class labels + for each data sample. + """ + return self._pipeline.predict(X) diff --git a/skfda/ml/classification/_neighbors_classifiers.py b/skfda/ml/classification/_neighbors_classifiers.py index 743d269ee..e2348b402 100644 --- a/skfda/ml/classification/_neighbors_classifiers.py +++ b/skfda/ml/classification/_neighbors_classifiers.py @@ -15,18 +15,23 @@ ) -class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, - ClassifierMixin, NeighborsClassifierMixin): +class KNeighborsClassifier( + NeighborsBase, + NeighborsMixin, + KNeighborsMixin, + ClassifierMixin, + NeighborsClassifierMixin, +): """Classifier implementing the k-nearest neighbors vote. Parameters: - n_neighbors: int, optional (default = 5) + n_neighbors (int, default = 5): Number of neighbors to use by default for :meth:`kneighbors` queries. - weights: str or callable, optional (default = 'uniform') - weight function used in prediction. Possible values: - - - 'uniform': uniform weights. All points in each neighborhood + weights (str or callable, default = 'uniform'): + Weight function used in prediction. + Possible values: + - 'uniform': uniform weights. All points in each neighborhood are weighted equally. - 'distance': weight points by the inverse of their distance. in this case, closer neighbors of a query point will have a @@ -34,34 +39,31 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, - [callable]: a user-defined function which accepts an array of distances, and returns an array of the same shape containing the weights. - - algorithm: {'auto', 'ball_tree', 'brute'}, optional + algorithm (string, optional): Algorithm used to compute the nearest neighbors: - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - 'brute' will use a brute-force search. - 'auto' will attempt to decide the most appropriate algorithm based on the values passed to :meth:`fit` method. - - leaf_size: int, optional (default = 30) - Leaf size passed to BallTree or KDTree. This can affect the + leaf_size (int, default = 30): + Leaf size passed to BallTree or KDTree. This can affect the speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the + required to store the tree. The optimal value depends on the nature of the problem. - metric: string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is + metric (string or callable, default + :func:`l2_distance `): + the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. - metric_params: dict, optional (default = None) + metric_params (dict, optional): Additional keyword arguments for the metric function. - n_jobs: int or None, optional (default=None) + n_jobs (int or None, optional): The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. - multivariate_metric: boolean, optional (default = False) + multivariate_metric (boolean, default = False): Indicates if the metric used is a sklearn distance between vectors (see :class:`~sklearn.neighbors.DistanceMetric`) or a functional metric of the module `skfda.misc.metrics` if ``False``. @@ -116,43 +118,39 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm """ - def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric='l2', metric_params=None, - n_jobs=1, multivariate_metric=False): - """Initialize the classifier.""" - super().__init__(n_neighbors=n_neighbors, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - multivariate_metric=multivariate_metric) - - def _init_estimator(self, sklearn_metric): - """Initialize the sklearn K neighbors estimator. - - Args: - sklearn_metric (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn K Neighbors estimator initialized. - """ - return _KNeighborsClassifier( - n_neighbors=self.n_neighbors, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sklearn_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) + def __init__( + self, + n_neighbors=5, + weights='uniform', + algorithm='auto', + leaf_size=30, + metric='l2', + metric_params=None, + n_jobs=1, + multivariate_metric=False, + ): + super().__init__( + n_neighbors=n_neighbors, + weights=weights, + algorithm=algorithm, + leaf_size=leaf_size, + metric=metric, + metric_params=metric_params, + n_jobs=n_jobs, + multivariate_metric=multivariate_metric, + ) def predict_proba(self, X): - """Return probability estimates for the test data X. + """Calculate probability estimates for the test data X. Args: X (:class:`FDataGrid` or array-like): FDataGrid with the test samples or array (n_query, n_indexed) if metric == 'precomputed'. + Returns: - p: array of shape = [n_samples, n_classes], or a list of n_outputs - of such arrays if n_outputs > 1. + p (array of shape = (n_samples, n_classes), or a list of n_outputs + of such arrays if n_outputs > 1): The class probabilities of the input samples. Classes are ordered by lexicographic order. """ @@ -162,20 +160,45 @@ def predict_proba(self, X): return self.estimator_.predict_proba(X) + def _init_estimator(self, sklearn_metric): + """Initialize the sklearn K neighbors estimator. + + Args: + sklearn_metric (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + """ + return _KNeighborsClassifier( + n_neighbors=self.n_neighbors, + weights=self.weights, + algorithm=self.algorithm, + leaf_size=self.leaf_size, + metric=sklearn_metric, + metric_params=self.metric_params, + n_jobs=self.n_jobs, + ) + -class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, - RadiusNeighborsMixin, ClassifierMixin, - NeighborsClassifierMixin): +class RadiusNeighborsClassifier( + NeighborsBase, + NeighborsMixin, + RadiusNeighborsMixin, + ClassifierMixin, + NeighborsClassifierMixin, +): """Classifier implementing a vote among neighbors within a given radius. Parameters: - radius: float, optional (default = 1.0) + radius (float, default = 1.0): Range of parameter space to use by default for :meth:`radius_neighbors` queries. - weights: str or callable - weight function used in prediction. Possible values: - - - 'uniform': uniform weights. All points in each neighborhood + weights (str or callable, default = 'uniform'): + Weight function used in prediction. + Possible values: + - 'uniform': uniform weights. All points in each neighborhood are weighted equally. - 'distance': weight points by the inverse of their distance. in this case, closer neighbors of a query point will have a @@ -183,41 +206,37 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, - [callable]: a user-defined function which accepts an array of distances, and returns an array of the same shape containing the weights. - - Uniform weights are used by default. - algorithm: {'auto', 'ball_tree', 'brute'}, optional + algorithm (string, optional): Algorithm used to compute the nearest neighbors: - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - 'brute' will use a brute-force search. - 'auto' will attempt to decide the most appropriate algorithm based on the values passed to :meth:`fit` method. - - leaf_size: int, optional (default = 30) - Leaf size passed to BallTree. This can affect the + leaf_size (int, default = 30): + Leaf size passed to BallTree or KDTree. This can affect the speed of the construction and query, as well as the memory required to store the tree. The optimal value depends on the nature of the problem. - metric: string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is + metric (string or callable, default + :func:`l2_distance `): + the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. - outlier_label: int, optional (default = None) + outlier_label (int, optional): Label, which is given for outlier samples (samples with no neighbors on given radius). If set to None, ValueError is raised, when outlier is detected. - metric_params: dict, optional (default = None) + metric_params (dict, optional): Additional keyword arguments for the metric function. - n_jobs: int or None, optional (default=None) + n_jobs (int or None, optional): The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. - multivariate_metric: boolean, optional (default = False) + multivariate_metric (boolean, default = False): Indicates if the metric used is a sklearn distance between vectors - (see :class:`sklearn.neighbors.DistanceMetric`) or a functional - metric of the module :mod:`skfda.misc.metrics`. + (see :class:`~sklearn.neighbors.DistanceMetric`) or a functional + metric of the module `skfda.misc.metrics` if ``False``. Examples: Firstly, we will create a toy dataset with 2 classes. @@ -258,14 +277,28 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm """ - def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric='l2', metric_params=None, - outlier_label=None, n_jobs=1, multivariate_metric=False): - """Initialize the classifier.""" - super().__init__(radius=radius, weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - multivariate_metric=multivariate_metric) + def __init__( + self, + radius=1.0, + weights='uniform', + algorithm='auto', + leaf_size=30, + metric='l2', + metric_params=None, + outlier_label=None, + n_jobs=1, + multivariate_metric=False, + ): + super().__init__( + radius=radius, + weights=weights, + algorithm=algorithm, + leaf_size=leaf_size, + metric=metric, + metric_params=metric_params, + n_jobs=n_jobs, + multivariate_metric=multivariate_metric, + ) self.outlier_label = outlier_label @@ -273,7 +306,7 @@ def _init_estimator(self, sklearn_metric): """Initialize the sklearn radius neighbors estimator. Args: - sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -281,7 +314,12 @@ def _init_estimator(self, sklearn_metric): Sklearn Radius Neighbors estimator initialized. """ return _RadiusNeighborsClassifier( - radius=self.radius, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sklearn_metric, metric_params=self.metric_params, - outlier_label=self.outlier_label, n_jobs=self.n_jobs) + radius=self.radius, + weights=self.weights, + algorithm=self.algorithm, + leaf_size=self.leaf_size, + metric=sklearn_metric, + metric_params=self.metric_params, + outlier_label=self.outlier_label, + n_jobs=self.n_jobs, + ) diff --git a/skfda/ml/clustering/__init__.py b/skfda/ml/clustering/__init__.py index 1ac00d603..dc414ebf9 100644 --- a/skfda/ml/clustering/__init__.py +++ b/skfda/ml/clustering/__init__.py @@ -1,3 +1,4 @@ """Clustering.""" +from ._hierarchical import AgglomerativeClustering from ._kmeans import BaseKMeans, FuzzyCMeans, KMeans from ._neighbors_clustering import NearestNeighbors diff --git a/skfda/ml/clustering/_hierarchical.py b/skfda/ml/clustering/_hierarchical.py new file mode 100644 index 000000000..0f7230412 --- /dev/null +++ b/skfda/ml/clustering/_hierarchical.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import enum +from typing import Callable, Generic, Optional, TypeVar, Union + +import joblib +import numpy as np +import sklearn.cluster +from sklearn.base import BaseEstimator, ClusterMixin +from typing_extensions import Literal + +from ...misc.metrics import PRECOMPUTED, Metric, PairwiseMetric, l2_distance +from ...misc.metrics._typing import _parse_metric, _PrecomputedTypes +from ...representation import FData + +kk = ["ward", "average", "complete"] + +MetricElementType = TypeVar( + "MetricElementType", + contravariant=True, + bound=FData, +) + +MetricOrPrecomputed = Union[Metric[MetricElementType], _PrecomputedTypes] +Connectivity = Union[ + np.ndarray, + Callable[[MetricElementType], np.ndarray], + None, +] + + +class LinkageCriterion(enum.Enum): + """Linkage criterion to use in :class:`AgglomerativeClustering`.""" + + # WARD = "ward" Not until + # https://github.com/scikit-learn/scikit-learn/issues/15287 is solved + COMPLETE = "complete" + AVERAGE = "average" + SINGLE = "single" + + +LinkageCriterionLike = Union[ + LinkageCriterion, + Literal["ward", "complete", "average", "single"], +] + + +class AgglomerativeClustering( # noqa: WPS230 + ClusterMixin, # type: ignore + BaseEstimator, # type: ignore + Generic[MetricElementType], +): + r""" + Agglomerative Clustering. + + Recursively merges the pair of clusters that minimally increases + a given linkage distance. + + Notes: + This class is an extension of + :class:`sklearn.cluster.AgglomerativeClustering` that accepts + functional data objects and metrics. Please check also the + documentation of the original class. + + Parameters: + n_clusters: + The number of clusters to find. It must be ``None`` if + ``distance_threshold`` is not ``None``. + metric: + Metric used to compute the linkage. + If it is ``skfda.misc.metrics.PRECOMPUTED`` or the string + ``"precomputed"``, a distance matrix (instead of a similarity + matrix) is needed as input for the fit method. + memory: + Used to cache the output of the computation of the tree. + By default, no caching is done. If a string is given, it is the + path to the caching directory. + connectivity: + Connectivity matrix. Defines for each sample the neighboring + samples following a given structure of the data. + This can be a connectivity matrix itself or a callable that + transforms the data into a connectivity matrix, such as derived + from kneighbors_graph. Default is None, i.e, the + hierarchical clustering algorithm is unstructured. + compute_full_tree: + Stop early the construction of the tree at n_clusters. This is + useful to decrease computation time if the number of clusters + is not small compared to the number of samples. This option is + useful only when specifying a connectivity matrix. Note also + that when varying the number of clusters and using caching, it + may be advantageous to compute the full tree. It must be ``True`` + if ``distance_threshold`` is not ``None``. By default + `compute_full_tree` is "auto", which is equivalent to `True` when + `distance_threshold` is not `None` or that `n_clusters` is + inferior to the maximum between 100 or `0.02 * n_samples`. + Otherwise, "auto" is equivalent to `False`. + linkage: + Which linkage criterion to use. The linkage criterion determines + which distance to use between sets of observation. The algorithm + will merge the pairs of clusters that minimize this criterion. + + - average uses the average of the distances of each observation of + the two sets. + - complete or maximum linkage uses the maximum distances between + all observations of the two sets. + - single uses the minimum of the distances between all observations + of the two sets. + distance_threshold: + The linkage distance threshold above which, clusters will not be + merged. If not ``None``, ``n_clusters`` must be ``None`` and + ``compute_full_tree`` must be ``True``. + + Attributes: + n_clusters\_: + The number of clusters found by the algorithm. If + ``distance_threshold=None``, it will be equal to the given + ``n_clusters``. + labels\_: + cluster labels for each point + n_leaves\_: + Number of leaves in the hierarchical tree. + n_connected_components\_: + The estimated number of connected components in the graph. + children\_ : + The children of each non-leaf node. Values less than `n_samples` + correspond to leaves of the tree which are the original samples. + A node `i` greater than or equal to `n_samples` is a non-leaf + node and has children `children_[i - n_samples]`. Alternatively + at the i-th iteration, children[i][0] and children[i][1] + are merged to form node `n_samples + i` + + + Examples: + >>> from skfda import FDataGrid + >>> from skfda.ml.clustering import AgglomerativeClustering + >>> import numpy as np + >>> data_matrix = np.array([[1, 2], [1, 4], [1, 0], + ... [4, 2], [4, 4], [4, 0]]) + >>> X = FDataGrid(data_matrix) + >>> clustering = AgglomerativeClustering( + ... linkage=AgglomerativeClustering.LinkageCriterion.COMPLETE, + ... ) + >>> clustering.fit(X) + AgglomerativeClustering(...) + >>> clustering.labels_.astype(np.int_) + array([0, 0, 1, 0, 0, 1]) + """ + + LinkageCriterion = LinkageCriterion + + def __init__( + self, + n_clusters: Optional[int] = 2, + *, + metric: MetricOrPrecomputed[MetricElementType] = l2_distance, + memory: Union[str, joblib.Memory, None] = None, + connectivity: Connectivity[MetricElementType] = None, + compute_full_tree: Union[Literal['auto'], bool] = 'auto', + linkage: LinkageCriterionLike, + distance_threshold: Optional[float] = None, + ) -> None: + self.n_clusters = n_clusters + self.metric = metric + self.memory = memory + self.connectivity = connectivity + self.compute_full_tree = compute_full_tree + self.linkage = linkage + self.distance_threshold = distance_threshold + + def _init_estimator(self) -> None: + linkage = LinkageCriterion(self.linkage) + + self._estimator = sklearn.cluster.AgglomerativeClustering( + n_clusters=self.n_clusters, + affinity='precomputed', + memory=self.memory, + connectivity=self.connectivity, + compute_full_tree=self.compute_full_tree, + linkage=linkage.value, + distance_threshold=self.distance_threshold, + ) + + def _copy_attrs(self) -> None: + self.n_clusters_: int = self._estimator.n_clusters_ + self.labels_: np.ndarray = self._estimator.labels_ + self.n_leaves_: int = self._estimator.n_leaves_ + self.n_connected_components_: int = ( + self._estimator.n_connected_components_ + ) + self.children_: np.ndarray = self._estimator.children_ + + def fit( # noqa: D102 + self, + X: MetricElementType, + y: None = None, + ) -> AgglomerativeClustering[MetricElementType]: + + self._init_estimator() + + metric = _parse_metric(self.metric) + + if metric is not PRECOMPUTED: + data = PairwiseMetric(metric)(X) + + self._estimator.fit(data, y) + + self._copy_attrs() + + return self + + def fit_predict( # noqa: D102 + self, + X: MetricElementType, + y: None = None, + ) -> np.ndarray: + + self._init_estimator() + + metric = _parse_metric(self.metric) + + if metric is not PRECOMPUTED: + data = PairwiseMetric(metric)(X) + + predicted = self._estimator.fit_predict(data, y) + + self._copy_attrs() + + return predicted diff --git a/skfda/ml/clustering/_kmeans.py b/skfda/ml/clustering/_kmeans.py index 2f9ef8bc2..d5138c034 100644 --- a/skfda/ml/clustering/_kmeans.py +++ b/skfda/ml/clustering/_kmeans.py @@ -1,18 +1,31 @@ """K-Means Algorithms Module.""" -from abc import abstractmethod +from __future__ import annotations + import warnings +from abc import abstractmethod +from typing import Any, Generic, Optional, Tuple, TypeVar +import numpy as np from sklearn.base import BaseEstimator, ClusterMixin, TransformerMixin from sklearn.utils import check_random_state from sklearn.utils.validation import check_is_fitted -import numpy as np +from ..._utils import RandomStateLike, _check_compatible_fdata +from ...misc.metrics import Metric, PairwiseMetric, l2_distance +from ...representation import FDataGrid +from ...representation._typing import NDArrayAny, NDArrayFloat, NDArrayInt -from ...misc.metrics import pairwise_distance, lp_distance +SelfType = TypeVar("SelfType", bound="BaseKMeans[Any]") +MembershipType = TypeVar("MembershipType", bound=NDArrayAny) -class BaseKMeans(BaseEstimator, ClusterMixin, TransformerMixin): +class BaseKMeans( + BaseEstimator, # type: ignore + ClusterMixin, # type: ignore + TransformerMixin, # type: ignore + Generic[MembershipType], +): """Base class to implement K-Means clustering algorithms. Class from which both :class:`K-Means @@ -21,32 +34,41 @@ class BaseKMeans(BaseEstimator, ClusterMixin, TransformerMixin): classes inherit. """ - def __init__(self, n_clusters, init, metric, n_init, max_iter, tol, - random_state): - """Initialization of the BaseKMeans class. + def __init__( + self, + *, + n_clusters: int = 2, + init: Optional[FDataGrid] = None, + metric: Metric[FDataGrid] = l2_distance, + n_init: int = 1, + max_iter: int = 100, + tol: float = 1e-4, + random_state: RandomStateLike = 0, + ): + """Initialize the BaseKMeans class. Args: - n_clusters (int, optional): Number of groups into which the samples + n_clusters: Number of groups into which the samples are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the + init: Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. - metric (optional): functional data metric. Defaults to - *lp_distance*. - n_init (int, optional): Number of time the k-means algorithm will + metric: functional data metric. Defaults to + *l2_distance*. + n_init: Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the + max_iter: Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids + tol: tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. - random_state (int, RandomState instance or None, optional): + random_state: Determines random number generation for centroid - initialization. ç Use an int to make the randomness + initialization. Use an int to make the randomness deterministic. Defaults to 0. See :term:`Glossary `. """ @@ -58,204 +80,267 @@ def __init__(self, n_clusters, init, metric, n_init, max_iter, tol, self.tol = tol self.random_state = random_state - def _check_clustering(self, fdata): - """Checks the arguments used in the - :func:`fit method `. + def _check_clustering(self, fdata: FDataGrid) -> FDataGrid: + """Check the arguments used in fit. Args: - fdata (FDataGrid object): Object whose samples + fdata: Object whose samples are classified into different groups. - """ + Returns: + Validated input. + + """ if fdata.dim_domain > 1: raise NotImplementedError( - "Only support 1 dimension on the domain.") + "Only support 1 dimension on the domain.", + ) if fdata.n_samples < 2: raise ValueError( - "The number of observations must be greater than 1.") + "The number of observations must be greater than 1.", + ) if self.n_clusters < 2: raise ValueError( - "The number of clusters must be greater than 1.") + "The number of clusters must be greater than 1.", + ) if self.n_init < 1: raise ValueError( - "The number of iterations must be greater than 0.") + "The number of iterations must be greater than 0.", + ) if self.init is not None and self.n_init != 1: self.n_init = 1 - warnings.warn("Warning: The number of iterations is ignored " - "because the init parameter is set.") - - if self.init is not None and self.init.data_matrix.shape != ( - self.n_clusters, fdata.ncol, fdata.dim_codomain): - raise ValueError("The init FDataGrid data_matrix should be of " - "shape (n_clusters, n_features, dim_codomain) " - "and gives the initial centers.") + warnings.warn( + "Warning: The number of iterations is ignored " + "because the init parameter is set.", + ) + + if ( + self.init is not None + and self.init.data_matrix.shape != ( + (self.n_clusters,) + fdata.data_matrix.shape[1:] + ) + ): + raise ValueError( + "The init FDataGrid data_matrix should be of " + "shape (n_clusters, n_features, dim_codomain) " + "and gives the initial centers.", + ) if self.max_iter < 1: raise ValueError( - "The number of maximum iterations must be greater than 0.") + "The number of maximum iterations must be greater than 0.", + ) if self.tol < 0: raise ValueError("The tolerance must be positive.") return fdata - def _tolerance(self, fdata): + def _tolerance(self, fdata: FDataGrid) -> float: variance = fdata.var() mean_variance = np.mean(variance[0].data_matrix) return mean_variance * self.tol - def _init_centroids(self, fdatagrid, random_state): - """Compute the initial centroids + def _init_centroids( + self, + fdatagrid: FDataGrid, + random_state: np.random.RandomState, + ) -> FDataGrid: + """ + Compute the initial centroids. Args: - data_matrix (ndarray): matrix with the data only of the - dimension of the image of the fdatagrid the algorithm is - classifying. - fdatagrid (FDataGrid object): Object whose samples are - classified into different groups. - random_state (RandomState object): random number generation for - centroid initialization. + fdatagrid: Object whose samples are classified into different + groups. + random_state: Random number generation for centroid initialization. Returns: - centroids (ndarray): initial centroids - """ + Initial centroids. + """ if self.init is None: - _, idx = np.unique(fdatagrid.data_matrix, - axis=0, return_index=True) + _, idx = np.unique( + fdatagrid.data_matrix, + axis=0, + return_index=True, + ) unique_data = fdatagrid[np.sort(idx)] if len(unique_data) < self.n_clusters: - return ValueError("Not enough unique data points to " - "initialize the requested number of " - "clusters") + raise ValueError( + "Not enough unique data points to " + "initialize the requested number of " + "clusters", + ) indices = random_state.permutation(len(unique_data))[ - :self.n_clusters] + :self.n_clusters + ] centroids = unique_data[indices] return centroids.copy() - else: - return self.init.copy() - def _check_params(self): + return self.init.copy() + + def _check_params(self) -> None: pass @abstractmethod - def _create_membership(self, n_samples): + def _create_membership(self, n_samples: int) -> MembershipType: pass @abstractmethod - def _update(self, fdata, membership_matrix, distances_to_centroids, - centroids): + def _update( + self, + fdata: FDataGrid, + membership_matrix: MembershipType, + distances_to_centroids: NDArrayFloat, + centroids: FDataGrid, + ) -> None: pass - def _algorithm(self, fdata, random_state): - """ Implementation of the Fuzzy K-Means algorithm for FDataGrid objects + def _algorithm( + self, + fdata: FDataGrid, + random_state: np.random.RandomState, + ) -> Tuple[NDArrayFloat, FDataGrid, NDArrayFloat, int]: + """ + Fuzzy K-Means algorithm. + + Implementation of the Fuzzy K-Means algorithm for FDataGrid objects of any dimension. Args: - fdata (FDataGrid object): Object whose samples are clustered, + fdata: Object whose samples are clustered, classified into different groups. - random_state (RandomState object): random number generation for + random_state: random number generation for centroid initialization. Returns: - (tuple): tuple containing: + Tuple containing: - membership values (numpy.ndarray): + membership values: membership value that observation has to each cluster. - centroids (numpy.ndarray: (n_clusters, ncol, dim_codomain)): + centroids: centroids for each cluster. - distances_to_centroids (numpy.ndarray: (n_samples, - n_clusters)): distances of each sample to each cluster. + distances_to_centroids: distances of each sample to each + cluster. - repetitions(int): number of iterations the algorithm was run. + repetitions: number of iterations the algorithm was run. """ repetitions = 0 centroids_old_matrix = np.zeros( - (self.n_clusters, fdata.ncol, fdata.dim_codomain)) + (self.n_clusters,) + fdata.data_matrix.shape[1:], + ) membership_matrix = self._create_membership(fdata.n_samples) centroids = self._init_centroids(fdata, random_state) centroids_old = centroids.copy(data_matrix=centroids_old_matrix) - pairwise_metric = pairwise_distance(self.metric) + pairwise_metric = PairwiseMetric(self.metric) tolerance = self._tolerance(fdata) - while (repetitions == 0 or - (not np.all(self.metric(centroids, centroids_old) < tolerance) - and repetitions < self.max_iter)): + while ( + repetitions == 0 + or ( + not np.all(self.metric(centroids, centroids_old) < tolerance) + and repetitions < self.max_iter + ) + ): centroids_old.data_matrix[...] = centroids.data_matrix - distances_to_centroids = pairwise_metric(fdata1=fdata, - fdata2=centroids) + distances_to_centroids = pairwise_metric(fdata, centroids) self._update( fdata=fdata, membership_matrix=membership_matrix, distances_to_centroids=distances_to_centroids, - centroids=centroids) + centroids=centroids, + ) repetitions += 1 - return (membership_matrix, centroids, - distances_to_centroids, repetitions) + return ( + membership_matrix, + centroids, + distances_to_centroids, + repetitions, + ) @abstractmethod - def _compute_inertia(self, membership, centroids, - distances_to_centroids): + def _compute_inertia( + self, + membership: MembershipType, + centroids: FDataGrid, + distances_to_centroids: NDArrayFloat, + ) -> float: pass - def fit(self, X, y=None, sample_weight=None): - """ Computes Fuzzy K-Means clustering calculating the attributes - *labels_*, *cluster_centers_*, *inertia_* and *n_iter_*. + def fit( + self: SelfType, + X: FDataGrid, + y: None = None, + sample_weight: None = None, + ) -> SelfType: + """ + Fit the model. Args: - X (FDataGrid object): Object whose samples are clusered, + X: Object whose samples are clusered, classified into different groups. - y (Ignored): present here for API consistency by convention. - sample_weight (Ignored): present here for API consistency by + y: present here for API consistency by convention. + sample_weight: present here for API consistency by convention. + + Returns: + Fitted model. + """ fdata = self._check_clustering(X) random_state = check_random_state(self.random_state) self._check_params() - best_inertia = None - best_membership = None - best_centroids = None - best_distances_to_centroids = None - best_n_iter = None + best_inertia = np.inf for _ in range(self.n_init): - (membership, centroids, - distances_to_centroids, n_iter) = ( - self._algorithm(fdata=fdata, - random_state=random_state)) - - inertia = self._compute_inertia(membership, centroids, - distances_to_centroids) - - if best_inertia is None or inertia < best_inertia: + ( + membership, + centroids, + distances_to_centroids, + n_iter, + ) = ( + self._algorithm( + fdata=fdata, + random_state=random_state, + ) + ) + + inertia = self._compute_inertia( + membership, + centroids, + distances_to_centroids, + ) + + if inertia < best_inertia: best_inertia = inertia best_membership = membership best_centroids = centroids best_distances_to_centroids = distances_to_centroids best_n_iter = n_iter - self.labels_ = best_membership + self._best_membership = best_membership + self.labels_ = self._prediction_from_membership(best_membership) self.cluster_centers_ = best_centroids self._distances_to_centers = best_distances_to_centroids self.inertia_ = best_inertia @@ -263,105 +348,128 @@ def fit(self, X, y=None, sample_weight=None): return self - def _check_test_data(self, fdatagrid): - """Checks that the FDataGrid object and the calculated centroids have - compatible shapes. - """ - if (fdatagrid.data_matrix.shape[1:3] - != self.cluster_centers_.data_matrix.shape[1:3]): - raise ValueError("The fdatagrid shape is not the one expected for " - "the calculated cluster_centers_.") - - def predict(self, X, sample_weight=None): + def _predict_membership( + self, + X: FDataGrid, + sample_weight: None = None, + ) -> MembershipType: """Predict the closest cluster each sample in X belongs to. Args: - X (FDataGrid object): Object whose samples are classified into - different groups. - y (Ignored): present here for API consistency by convention. - sample_weight (Ignored): present here for API consistency by - convention. + X: Object whose samples are classified into different groups. + sample_weight: present here for API consistency by convention. Returns: Label of each sample. + """ check_is_fitted(self) - self._check_test_data(X) + _check_compatible_fdata(self.cluster_centers_, X) membership_matrix = self._create_membership(X.n_samples) centroids = self.cluster_centers_.copy() - pairwise_metric = pairwise_distance(self.metric) + pairwise_metric = PairwiseMetric(self.metric) - distances_to_centroids = pairwise_metric(fdata1=X, - fdata2=centroids) + distances_to_centroids = pairwise_metric(X, centroids) self._update( fdata=X, membership_matrix=membership_matrix, distances_to_centroids=distances_to_centroids, - centroids=centroids) + centroids=centroids, + ) return membership_matrix - def transform(self, X): + @abstractmethod + def _prediction_from_membership( + self, + membership_matrix: MembershipType, + ) -> NDArrayInt: + pass + + def predict( + self, + X: FDataGrid, + sample_weight: None = None, + ) -> NDArrayInt: + """Predict the closest cluster each sample in X belongs to. + + Args: + X: Object whose samples are classified into different groups. + sample_weight: present here for API consistency by convention. + + Returns: + Label of each sample. + + """ + return self._prediction_from_membership( + self._predict_membership(X, sample_weight), + ) + + def transform(self, X: FDataGrid) -> NDArrayFloat: """Transform X to a cluster-distance space. Args: - X (FDataGrid object): Object whose samples are classified into + X: Object whose samples are classified into different groups. - y (Ignored): present here for API consistency by convention. - sample_weight (Ignored): present here for API consistency by - convention. Returns: - distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): + distances_to_centers: distances of each sample to each cluster. + """ check_is_fitted(self) - self._check_test_data(X) + _check_compatible_fdata(self.cluster_centers_, X) return self._distances_to_centers - def fit_transform(self, X, y=None, sample_weight=None): + def fit_transform( + self, + X: FDataGrid, + y: None = None, + sample_weight: None = None, + ) -> NDArrayFloat: """Compute clustering and transform X to cluster-distance space. Args: - X (FDataGrid object): Object whose samples are classified into - different groups. - y (Ignored): present here for API consistency by convention. - sample_weight (Ignored): present here for API consistency by - convention. + X: Object whose samples are classified into different groups. + y: present here for API consistency by convention. + sample_weight: present here for API consistency by convention. Returns: - distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): - distances of each sample to each cluster. + Distances of each sample to each cluster. + """ self.fit(X) return self._distances_to_centers - def score(self, X, y=None, sample_weight=None): + def score( + self, + X: FDataGrid, + y: None = None, + sample_weight: None = None, + ) -> float: """Opposite of the value of X on the K-means objective. Args: - X (FDataGrid object): Object whose samples are classified into + X: Object whose samples are classified into different groups. - y (Ignored): present here for API consistency by convention. - sample_weight (Ignored): present here for API consistency by + y: present here for API consistency by convention. + sample_weight: present here for API consistency by convention. Returns: - score (numpy.array: (fdatagrid.dim_codomain)): negative *inertia_* - attribute. + Negative ``inertia_`` attribute. """ check_is_fitted(self) - self._check_test_data(X) + _check_compatible_fdata(self.cluster_centers_, X) return -self.inertia_ -class KMeans(BaseKMeans): - r"""Representation and implementation of the K-Means algorithm - for the FdataGrid object. +class KMeans(BaseKMeans[NDArrayInt]): + r"""K-Means algorithm for functional data. Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a given dataset to be analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, @@ -413,39 +521,37 @@ class KMeans(BaseKMeans): object. Args: - n_clusters (int, optional): Number of groups into which the samples are + n_clusters: Number of groups into which the samples are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the + init: Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. - metric (optional): functional data metric. Defaults to - *lp_distance*. - n_init (int, optional): Number of time the k-means algorithm will be + metric: functional data metric. Defaults to + *l2_distance*. + n_init: Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the + max_iter: Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids + tol: Tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. - random_state (int, RandomState instance or None, optional): + random_state: Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. Defaults to 0. See :term:`Glossary `. Attributes: - labels_ (numpy.ndarray: n_samples): vector in which each entry contains - the cluster each observation belongs to. - cluster_centers_ (FDataGrid object): data_matrix of shape - (n_clusters, ncol, dim_codomain) and contains the centroids for - each cluster. - inertia_ (numpy.ndarray, (fdatagrid.dim_codomain)): Sum of squared - distances of samples to their closest cluster center for each + labels\_: Vector in which each entry contains the cluster each + observation belongs to. + cluster_centers\_: data_matrix of shape (n_clusters, ncol, + dim_codomain) and contains the centroids for each cluster. + inertia\_: Sum of squared distances of samples to their closest + cluster center for each dimension. + n_iter\_: number of iterations the algorithm was run for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.dim_codomain)): number of iterations - the algorithm was run for each dimension. Example: @@ -475,67 +581,52 @@ class KMeans(BaseKMeans): """ - def __init__(self, n_clusters=2, init=None, - metric=lp_distance, - n_init=1, max_iter=100, tol=1e-4, random_state=0): - """Initialization of the KMeans class. - - Args: - n_clusters (int, optional): Number of groups into which the samples - are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the - different clusters the algorithm starts with. Its data_marix - must be of the shape (n_clusters, fdatagrid.ncol, - fdatagrid.dim_codomain). Defaults to None, and the centers are - initialized randomly. - metric (optional): functional data metric. Defaults to - *lp_distance*. - n_init (int, optional): Number of time the k-means algorithm will - be run with different centroid seeds. The final results will - be the best output of n_init consecutive runs in terms - of inertia. - max_iter (int, optional): Maximum number of iterations of the - clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids - calculated with the previous ones in every single run of the - algorithm. - random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid - initialization. Use an int to make the randomness - deterministic. - Defaults to 0. - """ - super().__init__(n_clusters=n_clusters, init=init, metric=metric, - n_init=n_init, max_iter=max_iter, tol=tol, - random_state=random_state) - - def _compute_inertia(self, membership, centroids, - distances_to_centroids): - distances_to_their_center = np.choose(membership, - distances_to_centroids.T) + def _compute_inertia( + self, + membership: NDArrayInt, + centroids: FDataGrid, + distances_to_centroids: NDArrayFloat, + ) -> float: + distances_to_their_center = np.choose( + membership, + distances_to_centroids.T, + ) return np.sum(distances_to_their_center**2) - def _create_membership(self, n_samples): + def _create_membership(self, n_samples: int) -> NDArrayInt: return np.empty(n_samples, dtype=int) - def _update(self, fdata, membership_matrix, distances_to_centroids, - centroids): + def _prediction_from_membership( + self, + membership_matrix: NDArrayInt, + ) -> NDArrayInt: + return membership_matrix + + def _update( + self, + fdata: FDataGrid, + membership_matrix: NDArrayInt, + distances_to_centroids: NDArrayFloat, + centroids: FDataGrid, + ) -> None: membership_matrix[:] = np.argmin(distances_to_centroids, axis=1) for i in range(self.n_clusters): - indices, = np.where(membership_matrix == i) + indices = np.where(membership_matrix == i)[0] if len(indices) != 0: centroids.data_matrix[i] = np.average( - fdata.data_matrix[indices, ...], axis=0) + fdata.data_matrix[indices, ...], + axis=0, + ) -class FuzzyCMeans(BaseKMeans): - r""" Representation and implementation of the Fuzzy c-Means clustering - algorithm for the FDataGrid object. +class FuzzyCMeans(BaseKMeans[NDArrayFloat]): + r""" + Fuzzy c-Means clustering for functional data. Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a given dataset to be analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, @@ -593,41 +684,43 @@ class FuzzyCMeans(BaseKMeans): object. Args: - n_clusters (int, optional): Number of groups into which the samples are + n_clusters: Number of groups into which the samples are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the + init: Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. - metric (optional): functional data metric. Defaults to - *lp_distance*. - n_init (int, optional): Number of time the k-means algorithm will be + metric: functional data metric. Defaults to + *l2_distance*. + n_init: Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the + max_iter: Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids + tol: tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. - random_state (int, RandomState instance or None, optional): + random_state: Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. Defaults to 0. See :term:`Glossary `. - fuzzifier (int, optional): Scalar parameter used to specify the + fuzzifier: Scalar parameter used to specify the degree of fuzziness in the fuzzy algorithm. Defaults to 2. Attributes: - labels_ (numpy.ndarray: (n_samples, n_clusters)): 2-dimensional - matrix in which each row contains the cluster that observation - belongs to. - cluster_centers_ (FDataGrid object): data_matrix of shape + membership_degree\_: Matrix in which each entry contains the + probability of belonging to each group. + labels\_: Vector in which each entry contains the cluster each + observation belongs to (the one with the maximum membership + degree). + cluster_centers\_: data_matrix of shape (n_clusters, ncol, dim_codomain) and contains the centroids for each cluster. - inertia_ (numpy.ndarray, (fdatagrid.dim_codomain)): Sum of squared + inertia\_: Sum of squared distances of samples to their closest cluster center for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.dim_codomain)): number of iterations + n_iter\_: number of iterations the algorithm was run for each dimension. @@ -655,79 +748,114 @@ class FuzzyCMeans(BaseKMeans): """ - def __init__(self, n_clusters=2, init=None, - metric=lp_distance, n_init=1, max_iter=100, - tol=1e-4, random_state=0, fuzzifier=2): - """Initialization of the FuzzyKMeans class. - - Args: - n_clusters (int, optional): Number of groups into which the samples - are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the - different clusters the algorithm starts with. Its data_marix - must be of the shape (n_clusters, fdatagrid.ncol, - fdatagrid.dim_codomain). - Defaults to None, and the centers are initialized randomly. - metric (optional): functional data metric. Defaults to - *lp_distance*. - n_init (int, optional): Number of time the k-means algorithm will - be run with different centroid seeds. The final results will be - the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the - clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids - calculated with the previous ones in every single run of the - algorithm. - random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid - initialization. Use an int to make the randomness - deterministic. Defaults to 0. - fuzzifier (int, optional): Scalar parameter used to specify the - degree of fuzziness in the fuzzy algorithm. Defaults to 2. - - """ - super().__init__(n_clusters=n_clusters, init=init, metric=metric, - n_init=n_init, - max_iter=max_iter, tol=tol, random_state=random_state) + def __init__( + self, + *, + n_clusters: int = 2, + init: Optional[FDataGrid] = None, + metric: Metric[FDataGrid] = l2_distance, + n_init: int = 1, + max_iter: int = 100, + tol: float = 1e-4, + random_state: RandomStateLike = 0, + fuzzifier: float = 2, + ) -> None: + super().__init__( + n_clusters=n_clusters, + init=init, + metric=metric, + n_init=n_init, + max_iter=max_iter, + tol=tol, + random_state=random_state, + ) self.fuzzifier = fuzzifier - def _check_params(self): + @property + def membership_degree_(self) -> NDArrayFloat: + return self._best_membership + + def _check_params(self) -> None: if self.fuzzifier <= 1: raise ValueError("The fuzzifier parameter must be greater than 1.") - def _compute_inertia(self, membership, centroids, - distances_to_centroids): + def _compute_inertia( + self, + membership: NDArrayFloat, + centroids: FDataGrid, + distances_to_centroids: NDArrayFloat, + ) -> float: return np.sum( membership**self.fuzzifier * distances_to_centroids**2, ) - def _create_membership(self, n_samples): + def _create_membership(self, n_samples: int) -> NDArrayFloat: return np.empty((n_samples, self.n_clusters)) - def _update(self, fdata, membership_matrix, distances_to_centroids, - centroids): + def _prediction_from_membership( + self, + membership_matrix: NDArrayFloat, + ) -> NDArrayInt: + return np.argmax(membership_matrix, axis=1) + + def _update( + self, + fdata: FDataGrid, + membership_matrix: NDArrayFloat, + distances_to_centroids: NDArrayFloat, + centroids: FDataGrid, + ) -> None: # Divisions by zero allowed with np.errstate(divide='ignore'): - distances_to_centers_raised = (distances_to_centroids**( - 2 / (1 - self.fuzzifier))) + distances_to_centers_raised = ( + distances_to_centroids**(2 / (1 - self.fuzzifier)) + ) # Divisions infinity by infinity allowed with np.errstate(invalid='ignore'): - membership_matrix[:, :] = (distances_to_centers_raised - / np.sum( - distances_to_centers_raised, - axis=1, keepdims=True)) + membership_matrix[:, :] = ( + distances_to_centers_raised + / np.sum( + distances_to_centers_raised, + axis=1, + keepdims=True, + ) + ) # inf / inf divisions should be 1 in this context membership_matrix[np.isnan(membership_matrix)] = 1 membership_matrix_raised = np.power( - membership_matrix, self.fuzzifier) + membership_matrix, + self.fuzzifier, + ) - slice_denominator = ((slice(None),) + (np.newaxis,) * - (fdata.data_matrix.ndim - 1)) + slice_denominator = ( + (slice(None),) + (np.newaxis,) * (fdata.data_matrix.ndim - 1) + ) centroids.data_matrix[:] = ( - np.einsum('ij,i...->j...', membership_matrix_raised, - fdata.data_matrix) - / np.sum(membership_matrix_raised, axis=0)[slice_denominator]) + np.einsum( + 'ij,i...->j...', + membership_matrix_raised, + fdata.data_matrix, + ) + / np.sum(membership_matrix_raised, axis=0)[slice_denominator] + ) + + def predict_proba( + self, + X: FDataGrid, + sample_weight: None = None, + ) -> NDArrayFloat: + """Predict the probability of belonging to each cluster. + + Args: + X: Object whose samples are classified into different groups. + sample_weight: present here for API consistency by convention. + + Returns: + Probability of belonging to each cluster for each sample. + + """ + return self._predict_membership(X, sample_weight) diff --git a/skfda/ml/clustering/_neighbors_clustering.py b/skfda/ml/clustering/_neighbors_clustering.py index 3b305b961..143f62338 100644 --- a/skfda/ml/clustering/_neighbors_clustering.py +++ b/skfda/ml/clustering/_neighbors_clustering.py @@ -33,7 +33,7 @@ class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, required to store the tree. The optimal value depends on the nature of the problem. metric : string or callable, (default - :func:`lp_distance `) + :func:`l2_distance `) the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index 7ba5a1ac9..485151c9b 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -1,4 +1,5 @@ """Regression.""" +from ._historical_linear_model import HistoricalLinearRegression from ._linear_regression import LinearRegression from ._neighbors_regression import ( KNeighborsRegressor, diff --git a/skfda/ml/regression/_coefficients.py b/skfda/ml/regression/_coefficients.py index 67f30ab16..3a2d8af07 100644 --- a/skfda/ml/regression/_coefficients.py +++ b/skfda/ml/regression/_coefficients.py @@ -1,12 +1,18 @@ +from __future__ import annotations + +import abc from functools import singledispatch +from typing import Any, Generic, TypeVar import numpy as np from ...misc._math import inner_product from ...representation.basis import Basis, FDataBasis +CovariateType = TypeVar("CovariateType") + -class CoefficientInfo(): +class CoefficientInfo(abc.ABC, Generic[CovariateType]): """ Information about an estimated coefficient. @@ -15,10 +21,18 @@ class CoefficientInfo(): """ - def __init__(self, basis): + def __init__( + self, + basis: CovariateType, + ) -> None: self.basis = basis - def regression_matrix(self, X, y): + @abc.abstractmethod + def regression_matrix( + self, + X: CovariateType, + y: np.ndarray, + ) -> np.ndarray: """ Return the constant coefficients matrix for regression. @@ -26,29 +40,72 @@ def regression_matrix(self, X, y): X: covariate data for regression. y: target data for regression. + Returns: + Coefficients matrix. + """ - return np.atleast_2d(X) + pass - def convert_from_constant_coefs(self, coefs): + @abc.abstractmethod + def convert_from_constant_coefs( + self, + coefs: np.ndarray, + ) -> CovariateType: """ Return the coefficients object from the constant coefs. Parameters: coefs: estimated constant coefficients. - """ - return coefs + Returns: + Coefficient. - def inner_product(self, coefs, X): """ + pass + + @abc.abstractmethod + def inner_product( + self, + coefs: CovariateType, + X: CovariateType, + ) -> np.ndarray: + """ + Inner product. + Compute the inner product between the coefficient and the covariate. """ + pass + + +class CoefficientInfoNdarray(CoefficientInfo[np.ndarray]): + + def regression_matrix( # noqa: D102 + self, + X: np.ndarray, + y: np.ndarray, + ) -> np.ndarray: + + return np.atleast_2d(X) + + def convert_from_constant_coefs( # noqa: D102 + self, + coefs: np.ndarray, + ) -> np.ndarray: + + return coefs + + def inner_product( # noqa: D102 + self, + coefs: np.ndarray, + X: np.ndarray, + ) -> np.ndarray: + return inner_product(coefs, X) -class CoefficientInfoFDataBasis(CoefficientInfo): +class CoefficientInfoFDataBasis(CoefficientInfo[FDataBasis]): """ Information about a FDataBasis coefficient. @@ -57,7 +114,11 @@ class CoefficientInfoFDataBasis(CoefficientInfo): """ - def regression_matrix(self, X, y): + def regression_matrix( # noqa: D102 + self, + X: FDataBasis, + y: np.ndarray, + ) -> np.ndarray: # The matrix is the matrix of coefficients multiplied by # the matrix of inner products. @@ -65,32 +126,54 @@ def regression_matrix(self, X, y): self.inner_basis = X.basis.inner_product_matrix(self.basis) return xcoef @ self.inner_basis - def convert_from_constant_coefs(self, coefs): - return FDataBasis(self.basis, coefs.T) - - def inner_product(self, coefs, X): + def convert_from_constant_coefs( # noqa: D102 + self, + coefs: np.ndarray, + ) -> FDataBasis: + return FDataBasis(self.basis.basis, coefs.T) + + def inner_product( # noqa: D102 + self, + coefs: FDataBasis, + X: FDataBasis, + ) -> np.ndarray: # Efficient implementation of the inner product using the # inner product matrix previously computed return inner_product(coefs, X, inner_product_matrix=self.inner_basis.T) @singledispatch -def coefficient_info_from_covariate(X, y, **kwargs) -> CoefficientInfo: - """ - Make a coefficient info object from a covariate. +def coefficient_info_from_covariate( + X: CovariateType, + y: np.ndarray, + **_: Any, +) -> CoefficientInfo[CovariateType]: + """Make a coefficient info object from a covariate.""" + raise ValueError(f"Invalid type of covariate = {type(X)}.") - """ - return CoefficientInfo(basis=np.identity(X.shape[1], dtype=X.dtype)) + +@coefficient_info_from_covariate.register(np.ndarray) +def _coefficient_info_from_covariate_ndarray( + X: np.ndarray, + y: np.ndarray, + **_: Any, +) -> CoefficientInfo[np.ndarray]: + return CoefficientInfoNdarray(basis=np.identity(X.shape[1], dtype=X.dtype)) @coefficient_info_from_covariate.register(FDataBasis) -def coefficient_info_from_covariate_fdatabasis( - X: FDataBasis, y, **kwargs) -> CoefficientInfoFDataBasis: - basis = kwargs['basis'] +def _coefficient_info_from_covariate_fdatabasis( + X: FDataBasis, + y: np.ndarray, + *, + basis: Basis, + **_: Any, +) -> CoefficientInfoFDataBasis: + if basis is None: basis = X.basis if not isinstance(basis, Basis): raise TypeError(f"basis must be a Basis object, not {type(basis)}") - return CoefficientInfoFDataBasis(basis=basis) + return CoefficientInfoFDataBasis(basis=basis.to_basis()) diff --git a/skfda/ml/regression/_historical_linear_model.py b/skfda/ml/regression/_historical_linear_model.py new file mode 100644 index 000000000..b6d81d2e8 --- /dev/null +++ b/skfda/ml/regression/_historical_linear_model.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import math +from typing import Tuple, Union + +import numpy as np +import scipy.integrate +from sklearn.base import BaseEstimator, RegressorMixin +from sklearn.utils.validation import check_is_fitted + +from ..._utils import _cartesian_product, _pairwise_symmetric +from ...representation import FDataBasis, FDataGrid +from ...representation.basis import Basis, FiniteElement, VectorValued + +_MeanType = Union[FDataGrid, float] + + +def _pairwise_fem_inner_product( + basis_fd: FDataBasis, + fd: FDataGrid, + y_val: float, + grid: np.ndarray, +) -> np.ndarray: + + eval_grid_fem = np.concatenate( + ( + grid[:, None], + np.full( + shape=(len(grid), 1), + fill_value=y_val, + ), + ), + axis=1, + ) + + eval_fem = basis_fd(eval_grid_fem) + eval_fd = fd(grid) + + prod = eval_fem * eval_fd + integral = scipy.integrate.simps(prod, grid, axis=1) + return np.sum(integral, axis=-1) + + +def _inner_product_matrix( + basis: Basis, + fd: FDataGrid, + limits: Tuple[float, float], + y_val: float, +) -> np.ndarray: + """ + Compute inner products with the FEM basis. + + Compute the matrix of inner products of an FEM basis with a functional + data object over a range of x-values for a fixed y-value. The numerical + integration uses Romberg integration with the trapezoidal rule. + + Arguments: + basis: typically a FEM basis defined by a triangulation within a + rectangular domain. It is assumed that only the part of the mesh + that is within the upper left triangular is of interest. + fd: a regular functional data object. + limits: limits of integration, as a tuple of form + (lower limit, upper limit) + y_val: the fixed y value. + + Returns: + Matrix of inner products. + + """ + basis_fd = basis.to_basis() + grid = fd.grid_points[0] + grid_index = (grid >= limits[0]) & (grid <= limits[1]) + grid = grid[grid_index] + + return _pairwise_symmetric( + _pairwise_fem_inner_product, + basis_fd, + fd, + y_val=y_val, + grid=grid, + ) + + +def _design_matrix( + basis: Basis, + fd: FDataGrid, + pred_points: np.ndarray, +) -> np.ndarray: + """ + Compute the indefinite integrals of the curves over s up to each t-value. + + Arguments: + basis: typically a FEM basis defined by a triangulation within a + rectangular domain. It is assumed that only the part of the mesh + that is within the upper left triangular is of interest. + fd: a regular functional data object. + pred_points: points where ``fd`` is evaluated. + + Returns: + Design matrix. + + """ + matrix = np.array([ + _inner_product_matrix(basis, fd, limits=(0, t), y_val=t).T + for t in pred_points + ]) + + return np.swapaxes(matrix, 0, 1) + + +def _get_valid_points( + interval_len: float, + n_intervals: int, + lag: float, +) -> np.ndarray: + """Return the valid points as integer tuples.""" + interval_points = np.arange(n_intervals + 1) + full_grid_points = _cartesian_product((interval_points, interval_points)) + + past_points = full_grid_points[ + full_grid_points[:, 0] <= full_grid_points[:, 1] + ] + + discrete_lag = np.inf if lag == np.inf else math.ceil(lag / interval_len) + + return past_points[ + past_points[:, 1] - past_points[:, 0] <= discrete_lag + ] + + +def _get_triangles( + n_intervals: int, + valid_points: np.ndarray, +) -> np.ndarray: + """Construct the triangle grid given the valid points.""" + # A matrix where the (integer) coords of a point match + # to its index or to -1 if it does not exist. + indexes_matrix = np.full( + shape=(n_intervals + 1, n_intervals + 1), + fill_value=-1, + dtype=np.int_, + ) + + indexes_matrix[ + valid_points[:, 0], + valid_points[:, 1], + ] = np.arange(len(valid_points)) + + interval_without_end = np.arange(n_intervals) + + pts_coords = _cartesian_product( + (interval_without_end, interval_without_end), + ) + + pts_coords_x = pts_coords[:, 0] + pts_coords_y = pts_coords[:, 1] + + down_triangles = np.stack( + ( + indexes_matrix[pts_coords_x, pts_coords_y], + indexes_matrix[pts_coords_x + 1, pts_coords_y], + indexes_matrix[pts_coords_x + 1, pts_coords_y + 1], + ), + axis=1, + ) + + up_triangles = np.stack( + ( + indexes_matrix[pts_coords_x, pts_coords_y], + indexes_matrix[pts_coords_x, pts_coords_y + 1], + indexes_matrix[pts_coords_x + 1, pts_coords_y + 1], + ), + axis=1, + ) + + triangles = np.concatenate((down_triangles, up_triangles)) + has_wrong_index = np.any(triangles < 0, axis=1) + + return triangles[~has_wrong_index] + + +def _create_fem_basis( + start: float, + stop: float, + n_intervals: int, + lag: float, +) -> FiniteElement: + + interval_len = (stop - start) / n_intervals + + valid_points = _get_valid_points( + interval_len=interval_len, + n_intervals=n_intervals, + lag=lag, + ) + + final_points = valid_points * interval_len + start + + triangles = _get_triangles( + n_intervals=n_intervals, + valid_points=valid_points, + ) + + return FiniteElement( + vertices=final_points, + cells=triangles, + domain_range=((start, stop),) * 2, + ) + + +class HistoricalLinearRegression( + BaseEstimator, # type: ignore + RegressorMixin, # type: ignore +): + r"""Historical functional linear regression. + + This is a linear regression method where the covariate and the response are + both functions :math:`\mathbb{R}` to :math:`\mathbb{R}` with the same + domain. In order to predict the value of the response function at point + :math:`t`, only the information of the covariate at points :math:`s < t` is + used. Is thus an "historical" model in the sense that, if the domain + represents time, only the data from the past, or historical data, is used + to predict a given point. + + The model assumed by this method is: + + .. math:: + y_i = \alpha(t) + \int_{s_0(t)}^t x_i(s) \beta(s, t) ds + + where :math:`s_0(t) = \max(0, t - \delta)` and :math:`\delta` is a + predefined time lag that can be specified so that points far in the past + do not affect the predicted value. + + Args: + n_intervals: Number of intervals used to create the basis of the + coefficients. This will be a bidimensional + :class:`~skfda.representation.basis.FiniteElement` basis, and + this parameter indirectly specifies the number of + elements of that basis, and thus the granularity. + fit_intercept: Whether to calculate the intercept for this + model. If set to False, no intercept will be used in calculations + (i.e. data is expected to be centered). + lag: The maximum time lag at which points in the past can still + influence the prediction. + + Attributes: + basis_coef\_: The fitted coefficient function as a FDataBasis. + coef\_: The fitted coefficient function as a FDataGrid. + intercept\_: Independent term in the linear model. Set to the constant + function 0 if `fit_intercept = False`. + + Examples: + The following example test a case that conforms to this model. + + >>> from skfda import FDataGrid + >>> from skfda.ml.regression import HistoricalLinearRegression + >>> import numpy as np + >>> import scipy.integrate + + >>> random_state = np.random.RandomState(0) + >>> data_matrix = random_state.choice(10, size=(8, 6)) + >>> data_matrix + array([[5, 0, 3, 3, 7, 9], + [3, 5, 2, 4, 7, 6], + [8, 8, 1, 6, 7, 7], + [8, 1, 5, 9, 8, 9], + [4, 3, 0, 3, 5, 0], + [2, 3, 8, 1, 3, 3], + [3, 7, 0, 1, 9, 9], + [0, 4, 7, 3, 2, 7]]) + >>> intercept = random_state.choice(10, size=(1, 6)) + >>> intercept + array([[2, 0, 0, 4, 5, 5]]) + >>> y_data = scipy.integrate.cumtrapz( + ... data_matrix, + ... initial=0, + ... axis=1, + ... ) + intercept + >>> y_data + array([[ 2. , 2.5, 4. , 11. , 17. , 25. ], + [ 2. , 4. , 7.5, 14.5, 21. , 27.5], + [ 2. , 8. , 12.5, 20. , 27.5, 34.5], + [ 2. , 4.5, 7.5, 18.5, 28. , 36.5], + [ 2. , 3.5, 5. , 10.5, 15.5, 18. ], + [ 2. , 2.5, 8. , 16.5, 19.5, 22.5], + [ 2. , 5. , 8.5, 13. , 19. , 28. ], + [ 2. , 2. , 7.5, 16.5, 20. , 24.5]]) + >>> X = FDataGrid(data_matrix) + >>> y = FDataGrid(y_data) + >>> hist = HistoricalLinearRegression(n_intervals=8) + >>> _ = hist.fit(X, y) + >>> hist.predict(X).data_matrix[..., 0].round(1) + array([[ 2. , 2.5, 4. , 11. , 17. , 25. ], + [ 2. , 4. , 7.5, 14.5, 21. , 27.5], + [ 2. , 8. , 12.5, 20. , 27.5, 34.5], + [ 2. , 4.5, 7.5, 18.5, 28. , 36.5], + [ 2. , 3.5, 5. , 10.5, 15.5, 18. ], + [ 2. , 2.5, 8. , 16.5, 19.5, 22.5], + [ 2. , 5. , 8.5, 13. , 19. , 28. ], + [ 2. , 2. , 7.5, 16.5, 20. , 24.5]]) + >>> abs(hist.intercept_.data_matrix[..., 0].round()) + array([[ 2., 0., 0., 4., 5., 5.]]) + + References: + Malfait, N., & Ramsay, J. O. (2003). The historical functional linear + model. Canadian Journal of Statistics, 31(2), 115-128. + + """ + + def __init__( + self, + *, + n_intervals: int, + fit_intercept: bool = True, + lag: float = math.inf, + ) -> None: + self.n_intervals = n_intervals + self.fit_intercept = fit_intercept + self.lag = lag + + def _center_X_y( + self, + X: FDataGrid, + y: FDataGrid, + ) -> Tuple[FDataGrid, FDataGrid, _MeanType, _MeanType]: + + X_mean: Union[FDataGrid, float] = ( + X.mean() if self.fit_intercept else 0 + ) + X_centered = X - X_mean + y_mean: Union[FDataGrid, float] = ( + y.mean() if self.fit_intercept else 0 + ) + y_centered = y - y_mean + + return X_centered, y_centered, X_mean, y_mean + + def _fit_and_return_centered_matrix( + self, + X: FDataGrid, + y: FDataGrid, + ) -> Tuple[np.ndarray, _MeanType]: + + X_centered, y_centered, X_mean, y_mean = self._center_X_y(X, y) + + self._pred_points = y_centered.grid_points[0] + self._pred_domain_range = y_centered.domain_range[0] + + fem_basis = _create_fem_basis( + start=X_centered.domain_range[0][0], + stop=X_centered.domain_range[0][1], + n_intervals=self.n_intervals, + lag=self.lag, + ) + + self._basis = VectorValued( + [fem_basis] * X_centered.dim_codomain + ) + + design_matrix = _design_matrix( + self._basis, + X_centered, + pred_points=self._pred_points, + ) + design_matrix = design_matrix.reshape(-1, design_matrix.shape[-1]) + + self._coef_coefs = np.linalg.lstsq( + design_matrix, + y_centered.data_matrix[:, ..., 0].ravel(), + rcond=None, + )[0] + + self.basis_coef_ = FDataBasis( + basis=self._basis, + coefficients=self._coef_coefs, + ) + + self.coef_ = self.basis_coef_.to_grid( + grid_points=[X.grid_points[0]] * 2, + ) + + if self.fit_intercept: + assert isinstance(X_mean, FDataGrid) + self.intercept_ = ( + y_mean - self._predict_no_intercept(X_mean) + ) + else: + self.intercept_ = y.copy( + data_matrix=np.zeros_like(y.data_matrix[0]), + ) + + return design_matrix, y_mean + + def _prediction_from_matrix(self, design_matrix: np.ndarray) -> FDataGrid: + + points = (design_matrix @ self._coef_coefs).reshape( + -1, + len(self._pred_points), + ) + + return FDataGrid( + points, + grid_points=self._pred_points, + domain_range=self._pred_domain_range, + ) + + def fit( # noqa: D102 + self, + X: FDataGrid, + y: FDataGrid, + ) -> HistoricalLinearRegression: + + self._fit_and_return_centered_matrix(X, y) + return self + + def fit_predict( # noqa: D102 + self, + X: FDataGrid, + y: FDataGrid, + ) -> FDataGrid: + + design_matrix, y_mean = self._fit_and_return_centered_matrix(X, y) + return ( + self._prediction_from_matrix(design_matrix) + + y_mean + ) + + def _predict_no_intercept(self, X: FDataGrid) -> FDataGrid: + + design_matrix = _design_matrix( + self._basis, + X, + pred_points=self._pred_points, + ) + design_matrix = design_matrix.reshape(-1, design_matrix.shape[-1]) + + return self._prediction_from_matrix(design_matrix) + + def predict(self, X: FDataGrid) -> FDataGrid: # noqa: D102 + + check_is_fitted(self) + + return self._predict_no_intercept(X) + self.intercept_ diff --git a/skfda/ml/regression/_linear_regression.py b/skfda/ml/regression/_linear_regression.py index 383987cf0..5de4d4eb1 100644 --- a/skfda/ml/regression/_linear_regression.py +++ b/skfda/ml/regression/_linear_regression.py @@ -1,18 +1,58 @@ -from collections.abc import Iterable +from __future__ import annotations + import itertools import warnings +from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union +import numpy as np from sklearn.base import BaseEstimator, RegressorMixin from sklearn.utils.validation import check_is_fitted -import numpy as np - -from ...misc.regularization import compute_penalty_matrix +from ...misc.lstsq import solve_regularized_weighted_lstsq +from ...misc.regularization import ( + TikhonovRegularization, + compute_penalty_matrix, +) from ...representation import FData -from ._coefficients import coefficient_info_from_covariate - - -class LinearRegression(BaseEstimator, RegressorMixin): +from ...representation.basis import Basis +from ._coefficients import CoefficientInfo, coefficient_info_from_covariate + +RegularizationType = Union[ + TikhonovRegularization[Any], + Sequence[Optional[TikhonovRegularization[Any]]], + None, +] + +RegularizationIterableType = Union[ + TikhonovRegularization[Any], + Iterable[Optional[TikhonovRegularization[Any]]], + None, +] + +AcceptedDataType = Union[ + FData, + np.ndarray, +] + +AcceptedDataCoefsType = Union[ + CoefficientInfo[FData], + CoefficientInfo[np.ndarray], +] + +BasisCoefsType = Sequence[Optional[Basis]] + +ArgcheckResultType = Tuple[ + List[AcceptedDataType], + np.ndarray, + Optional[np.ndarray], + List[AcceptedDataCoefsType], +] + + +class LinearRegression( + BaseEstimator, # type: ignore + RegressorMixin, # type: ignore +): r"""Linear regression with multivariate response. This is a regression algorithm equivalent to multivariate linear @@ -38,7 +78,7 @@ class LinearRegression(BaseEstimator, RegressorMixin): for a functional covariate, the same basis is assumed. If this parameter is ``None`` (the default), it is assumed that ``None`` is provided for all covariates. - fit_intercept (bool): Whether to calculate the intercept for this + fit_intercept: Whether to calculate the intercept for this model. If set to False, no intercept will be used in calculations (i.e. data is expected to be centered). regularization (int, iterable or :class:`Regularization`): If it is @@ -55,14 +95,13 @@ class LinearRegression(BaseEstimator, RegressorMixin): ``None``. Attributes: - coef_ (iterable): A list containing the weight coefficient for each + coef\_: A list containing the weight coefficient for each covariate. For multivariate data, the covariate is a Numpy array. For functional data, the covariate is a FDataBasis object. - intercept_ (float): Independent term in the linear model. Set to 0.0 + intercept\_: Independent term in the linear model. Set to 0.0 if `fit_intercept = False`. Examples: - >>> from skfda.ml.regression import LinearRegression >>> from skfda.representation.basis import (FDataBasis, Monomial, ... Constant) @@ -116,22 +155,36 @@ class LinearRegression(BaseEstimator, RegressorMixin): """ - def __init__(self, *, coef_basis=None, fit_intercept=True, - regularization=None): + def __init__( + self, + *, + coef_basis: Optional[BasisCoefsType] = None, + fit_intercept: bool = True, + regularization: RegularizationType = None, + ) -> None: self.coef_basis = coef_basis self.fit_intercept = fit_intercept self.regularization = regularization - def fit(self, X, y=None, sample_weight=None): + def fit( # noqa: D102 + self, + X: Union[AcceptedDataType, Sequence[AcceptedDataType]], + y: np.ndarray, + sample_weight: Optional[np.ndarray] = None, + ) -> LinearRegression: - X, y, sample_weight, coef_info = self._argcheck_X_y( - X, y, sample_weight, self.coef_basis) + X_new, y, sample_weight, coef_info = self._argcheck_X_y( + X, + y, + sample_weight, + self.coef_basis, + ) - regularization = self.regularization + regularization: RegularizationIterableType = self.regularization if self.fit_intercept: new_x = np.ones((len(y), 1)) - X = [new_x] + X + X_new = [new_x] + X_new coef_info = [coefficient_info_from_covariate(new_x, y)] + coef_info if isinstance(regularization, Iterable): @@ -139,8 +192,10 @@ def fit(self, X, y=None, sample_weight=None): elif regularization is not None: regularization = (None, regularization) - inner_products_list = [c.regression_matrix(x, y) - for x, c in zip(X, coef_info)] + inner_products_list = [ + c.regression_matrix(x, y) + for x, c in zip(X_new, coef_info) + ] # This is C @ J inner_products = np.concatenate(inner_products_list, axis=1) @@ -152,30 +207,34 @@ def fit(self, X, y=None, sample_weight=None): penalty_matrix = compute_penalty_matrix( basis_iterable=(c.basis for c in coef_info), regularization_parameter=1, - regularization=regularization) + regularization=regularization, + ) - if self.fit_intercept and hasattr(penalty_matrix, "shape"): + if self.fit_intercept and penalty_matrix is not None: # Intercept is not penalized penalty_matrix[0, 0] = 0 - gram_inner_x_coef = inner_products.T @ inner_products + penalty_matrix - inner_x_coef_y = inner_products.T @ y + basiscoefs = solve_regularized_weighted_lstsq( + coefs=inner_products, + result=y, + penalty_matrix=penalty_matrix, + ) coef_lengths = np.array([i.shape[1] for i in inner_products_list]) coef_start = np.cumsum(coef_lengths) - - basiscoefs = np.linalg.solve(gram_inner_x_coef, inner_x_coef_y) basiscoef_list = np.split(basiscoefs, coef_start) # Express the coefficients in functional form - coefs = [c.convert_from_constant_coefs(bcoefs) - for c, bcoefs in zip(coef_info, basiscoef_list)] + coefs = [ + c.convert_from_constant_coefs(bcoefs) + for c, bcoefs in zip(coef_info, basiscoef_list) + ] if self.fit_intercept: self.intercept_ = coefs[0] coefs = coefs[1:] else: - self.intercept_ = 0.0 + self.intercept_ = np.zeros(1) self.coef_ = coefs self._coef_info = coef_info @@ -183,15 +242,22 @@ def fit(self, X, y=None, sample_weight=None): return self - def predict(self, X): - from ...misc import inner_product + def predict( # noqa: D102 + self, + X: Union[AcceptedDataType, Sequence[AcceptedDataType]], + ) -> np.ndarray: check_is_fitted(self) X = self._argcheck_X(X) - result = np.sum([coef_info.inner_product(coef, x) - for coef, x, coef_info - in zip(self.coef_, X, self._coef_info)], axis=0) + result = np.sum( + [ + coef_info.inner_product(coef, x) + for coef, x, coef_info + in zip(self.coef_, X, self._coef_info) + ], + axis=0, + ) result += self.intercept_ @@ -200,8 +266,11 @@ def predict(self, X): return result - def _argcheck_X(self, X): - if isinstance(X, FData) or isinstance(X, np.ndarray): + def _argcheck_X( + self, + X: Union[AcceptedDataType, Sequence[AcceptedDataType]], + ) -> Sequence[AcceptedDataType]: + if isinstance(X, (FData, np.ndarray)): X = [X] X = [x if isinstance(x, FData) else np.asarray(x) for x in X] @@ -211,41 +280,56 @@ def _argcheck_X(self, X): return X - def _argcheck_X_y(self, X, y, sample_weight=None, coef_basis=None): - """Do some checks to types and shapes""" - + def _argcheck_X_y( + self, + X: Union[AcceptedDataType, Sequence[AcceptedDataType]], + y: np.ndarray, + sample_weight: Optional[np.ndarray] = None, + coef_basis: Optional[BasisCoefsType] = None, + ) -> ArgcheckResultType: + """Do some checks to types and shapes.""" # TODO: Add support for Dataframes - X = self._argcheck_X(X) + new_X = self._argcheck_X(X) if any(isinstance(i, FData) for i in y): raise ValueError( - "Some of the response variables are not scalar") + "Some of the response variables are not scalar", + ) y = np.asarray(y) if coef_basis is None: - coef_basis = [None] * len(X) + coef_basis = [None] * len(new_X) - if len(coef_basis) != len(X): - raise ValueError("Number of regression coefficients does " - "not match number of independent variables.") + if len(coef_basis) != len(new_X): + raise ValueError( + "Number of regression coefficients does " + "not match number of independent variables.", + ) - if any(len(y) != len(x) for x in X): - raise ValueError("The number of samples on independent and " - "dependent variables should be the same") + if any(len(y) != len(x) for x in new_X): + raise ValueError( + "The number of samples on independent and " + "dependent variables should be the same", + ) - coef_info = [coefficient_info_from_covariate(x, y, basis=b) - for x, b in zip(X, coef_basis)] + coef_info = [ + coefficient_info_from_covariate(x, y, basis=b) + for x, b in zip(new_X, coef_basis) + ] if sample_weight is not None: if len(sample_weight) != len(y): - raise ValueError("The number of sample weights should be " - "equal to the number of samples.") + raise ValueError( + "The number of sample weights should be " + "equal to the number of samples.", + ) if np.any(np.array(sample_weight) < 0): raise ValueError( - "The sample weights should be non negative values") + "The sample weights should be non negative values", + ) - return X, y, sample_weight, coef_info + return new_X, y, sample_weight, coef_info diff --git a/skfda/ml/regression/_neighbors_regression.py b/skfda/ml/regression/_neighbors_regression.py index 9308f824b..7da0e4316 100644 --- a/skfda/ml/regression/_neighbors_regression.py +++ b/skfda/ml/regression/_neighbors_regression.py @@ -57,7 +57,7 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, required to store the tree. The optimal value depends on the nature of the problem. metric : string or callable, (default - :func:`lp_distance `) + :func:`l2_distance `) the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. @@ -219,7 +219,7 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, required to store the tree. The optimal value depends on the nature of the problem. metric : string or callable, (default - :func:`lp_distance `) + :func:`l2_distance `) the distance metric to use for the tree. The default metric is the L2 distance. See the documentation of the metrics module for a list of available metrics. diff --git a/skfda/preprocessing/dim_reduction/__init__.py b/skfda/preprocessing/dim_reduction/__init__.py index b079520b4..765694079 100644 --- a/skfda/preprocessing/dim_reduction/__init__.py +++ b/skfda/preprocessing/dim_reduction/__init__.py @@ -1,2 +1,13 @@ -from . import projection -from . import variable_selection +"""Dim reduction.""" +from __future__ import annotations + +import importlib +from typing import Any + +from . import feature_extraction, variable_selection + + +def __getattr__(name: str) -> Any: + if name == "projection": + return importlib.import_module(f".{name}", __name__) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/skfda/preprocessing/dim_reduction/feature_extraction/__init__.py b/skfda/preprocessing/dim_reduction/feature_extraction/__init__.py new file mode 100644 index 000000000..16355e236 --- /dev/null +++ b/skfda/preprocessing/dim_reduction/feature_extraction/__init__.py @@ -0,0 +1,3 @@ +"""Feature extraction.""" +from ._ddg_transformer import DDGTransformer +from ._fpca import FPCA diff --git a/skfda/preprocessing/dim_reduction/feature_extraction/_ddg_transformer.py b/skfda/preprocessing/dim_reduction/feature_extraction/_ddg_transformer.py new file mode 100644 index 000000000..ffe150f2d --- /dev/null +++ b/skfda/preprocessing/dim_reduction/feature_extraction/_ddg_transformer.py @@ -0,0 +1,132 @@ +"""Feature extraction transformers for dimensionality reduction.""" +from __future__ import annotations + +from typing import Generic, Sequence, TypeVar, Union + +import numpy as np +from numpy import ndarray +from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted + +from ...._utils import _classifier_fit_depth_methods +from ....exploratory.depth import Depth, ModifiedBandDepth +from ....representation.grid import FData + +T = TypeVar("T", bound=FData) + + +class DDGTransformer( + BaseEstimator, # type: ignore + TransformerMixin, # type: ignore + Generic[T], +): + r"""Generalized depth-versus-depth (DD) transformer for functional data. + + This transformer takes a list of k depths and performs the following map: + + .. math:: + \mathcal{X} &\rightarrow \mathbb{R}^G \\ + x &\rightarrow \textbf{d} = (D_1^1(x), D_1^2(x),...,D_g^k(x)) + + Where :math:`D_i^j(x)` is the depth of the point :math:`x` with respect to + the data in the :math:`i`-th group using the :math:`j`-th depth of the + provided list. + + Note that :math:`\mathcal{X}` is possibly multivariate, that is, + :math:`\mathcal{X} = \mathcal{X}_1 \times ... \times \mathcal{X}_p`. + + Parameters: + depth_method: + The depth class or sequence of depths to use when calculating + the depth of a test sample in a class. See the documentation of + the depths module for a list of available depths. By default it + is ModifiedBandDepth. + + Examples: + Firstly, we will import and split the Berkeley Growth Study dataset + + >>> from skfda.datasets import fetch_growth + >>> from sklearn.model_selection import train_test_split + >>> dataset = fetch_growth() + >>> fd = dataset['data'] + >>> y = dataset['target'] + >>> X_train, X_test, y_train, y_test = train_test_split( + ... fd, y, test_size=0.25, stratify=y, random_state=0) + + >>> from skfda.preprocessing.dim_reduction.feature_extraction import \ + ... DDGTransformer + >>> from sklearn.pipeline import make_pipeline + >>> from sklearn.neighbors import KNeighborsClassifier + + We classify by first transforming our data using the defined map + and then using KNN + + >>> pipe = make_pipeline(DDGTransformer(), KNeighborsClassifier()) + >>> pipe.fit(X_train, y_train) + Pipeline(steps=[('ddgtransformer', + DDGTransformer(depth_method=[ModifiedBandDepth()])), + ('kneighborsclassifier', KNeighborsClassifier())]) + + We can predict the class of new samples + + >>> pipe.predict(X_test) + array([1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1]) + + Finally, we calculate the mean accuracy for the test data + + >>> pipe.score(X_test, y_test) + 0.875 + + References: + Cuesta-Albertos, J. A., Febrero-Bande, M. and Oviedo de la Fuente, M. + (2017). The DDG-classifier in the functional setting. + TEST, 26. 119-142. + """ + + def __init__( + self, + depth_method: Union[Depth[T], Sequence[Depth[T]], None] = None, + ) -> None: + self.depth_method = depth_method + + def fit(self, X: T, y: ndarray) -> DDGTransformer[T]: + """Fit the model using X as training data and y as target values. + + Args: + X: FDataGrid with the training data. + y: Target values of shape = (n_samples). + + Returns: + self + """ + if self.depth_method is None: + self.depth_method = ModifiedBandDepth() + + if isinstance(self.depth_method, Depth): + self.depth_method = [self.depth_method] + + classes, class_depth_methods = _classifier_fit_depth_methods( + X, y, self.depth_method, + ) + + self._classes = classes + self.class_depth_methods_ = class_depth_methods + + return self + + def transform(self, X: T) -> ndarray: + """Transform the provided data using the defined map. + + Args: + X: FDataGrid with the test samples. + + Returns: + Array of shape (n_samples, G). + """ + sklearn_check_is_fitted(self) + + return np.transpose([ + depth_method.predict(X) + for depth_method in self.class_depth_methods_ + ]) diff --git a/skfda/preprocessing/dim_reduction/projection/_fpca.py b/skfda/preprocessing/dim_reduction/feature_extraction/_fpca.py similarity index 52% rename from skfda/preprocessing/dim_reduction/projection/_fpca.py rename to skfda/preprocessing/dim_reduction/feature_extraction/_fpca.py index 07b8a07df..f551fc0e0 100644 --- a/skfda/preprocessing/dim_reduction/projection/_fpca.py +++ b/skfda/preprocessing/dim_reduction/feature_extraction/_fpca.py @@ -1,36 +1,51 @@ """Functional Principal Component Analysis Module.""" +from __future__ import annotations + +from typing import Callable, Optional, TypeVar, Union + import numpy as np -from scipy.linalg import solve_triangular from sklearn.base import BaseEstimator, TransformerMixin from sklearn.decomposition import PCA -from skfda.misc.regularization import compute_penalty_matrix -from skfda.representation.basis import FDataBasis -from skfda.representation.grid import FDataGrid +from scipy.linalg import solve_triangular + +from ....misc.regularization import ( + TikhonovRegularization, + compute_penalty_matrix, +) +from ....representation import FData +from ....representation._typing import ArrayLike +from ....representation.basis import Basis, FDataBasis +from ....representation.grid import FDataGrid -__author__ = "Yujian Hong" -__email__ = "yujian.hong@estudiante.uam.es" +Function = TypeVar("Function", bound=FData) +WeightsCallable = Callable[[np.ndarray], np.ndarray] -class FPCA(BaseEstimator, TransformerMixin): - """Class that implements functional principal component analysis for both +class FPCA( + BaseEstimator, # type: ignore + TransformerMixin, # type: ignore +): + r""" + Principal component analysis. + + Class that implements functional principal component analysis for both basis and grid representations of the data. Most parameters are shared - when fitting a FDataBasis or FDataGrid, except weights and components_basis. + when fitting a FDataBasis or FDataGrid, except weights and + ``components_basis``. Parameters: - n_components (int): number of principal components to obtain from + n_components: Number of principal components to keep from functional principal component analysis. Defaults to 3. - centering (bool): if True then calculate the mean of the functional data - object and center the data first. Defaults to True. If True the - passed FDataBasis object is modified. - regularization (Regularization): - Regularization object to be applied. - components_basis (Basis): the basis in which we want the principal - components. We can use a different basis than the basis contained in - the passed FDataBasis object. This parameter is only used when + centering: If ``True`` then calculate the mean of the functional + data object and center the data first. Defaults to ``True``. + regularization: Regularization object to be applied. + components_basis: The basis in which we want the principal + components. We can use a different basis than the basis contained + in the passed FDataBasis object. This parameter is only used when fitting a FDataBasis. - weights (numpy.array or callable): the weights vector used for + weights: the weights vector used for discrete integration. If none then the trapezoidal rule is used for computing the weights. If a callable object is passed, then the weight vector will be obtained by evaluating the object at the @@ -38,19 +53,19 @@ class FPCA(BaseEstimator, TransformerMixin): This parameter is only used when fitting a FDataGrid. Attributes: - components_ (FData): this contains the principal components in a + components\_ (FData): this contains the principal components in a basis representation. - explained_variance_ (array_like): The amount of variance explained by + explained_variance\_ (array_like): The amount of variance explained by each of the selected components. - explained_variance_ratio_ (array_like): this contains the percentage of - variance explained by each principal component. - mean_ (FData): mean of the train data. + explained_variance_ratio\_ (array_like): this contains the percentage + of variance explained by each principal component. + mean\_ (FData): mean of the train data. Examples: - Construct an artificial FDataBasis object and run FPCA with this object. - The resulting principal components are not compared because there are - several equivalent possibilities. + Construct an artificial FDataBasis object and run FPCA with this + object. The resulting principal components are not compared because + there are several equivalent possibilities. >>> import skfda >>> data_matrix = np.array([[1.0, 0.0], [0.0, 2.0]]) @@ -75,44 +90,52 @@ class FPCA(BaseEstimator, TransformerMixin): >>> fpca_grid = FPCA(2) >>> fpca_grid = fpca_grid.fit(fd) - """ - def __init__(self, - n_components=3, - centering=True, - regularization=None, - weights=None, - components_basis=None - ): + def __init__( + self, + n_components: int = 3, + centering: bool = True, + regularization: Optional[TikhonovRegularization[FData]] = None, + weights: Optional[Union[ArrayLike, WeightsCallable]] = None, + components_basis: Optional[Basis] = None, + ) -> None: self.n_components = n_components self.centering = centering self.regularization = regularization self.weights = weights self.components_basis = components_basis - def _center_if_necessary(self, X, *, learn_mean=True): + def _center_if_necessary( + self, + X: Function, + *, + learn_mean: bool = True, + ) -> Function: if learn_mean: self.mean_ = X.mean() return X - self.mean_ if self.centering else X - def _fit_basis(self, X: FDataBasis, y=None): - """Computes the first n_components principal components and saves them. + def _fit_basis( + self, + X: FDataBasis, + y: None = None, + ) -> FPCA: + """ + Compute the first n_components principal components and saves them. + The eigenvalues associated with these principal components are also saved. For more details about how it is implemented please view the referenced book. Args: - X (FDataBasis): - the functional data object to be analysed in basis - representation - y (None, not used): - only present for convention of a fit function + X: The functional data object to be analysed. + y: Ignored. Returns: - self (object) + self References: .. [RS05-8-4-2] Ramsay, J., Silverman, B. W. (2005). Basis function @@ -120,24 +143,30 @@ def _fit_basis(self, X: FDataBasis, y=None): (pp. 161-164). Springer. """ - # the maximum number of components is established by the target basis # if the target basis is available. - n_basis = (self.components_basis.n_basis if self.components_basis - else X.basis.n_basis) + n_basis = ( + self.components_basis.n_basis + if self.components_basis + else X.basis.n_basis + ) n_samples = X.n_samples # check that the number of components is smaller than the sample size if self.n_components > X.n_samples: - raise AttributeError("The sample size must be bigger than the " - "number of components") + raise AttributeError( + "The sample size must be bigger than the " + "number of components", + ) # check that we do not exceed limits for n_components as it should # be smaller than the number of attributes of the basis if self.n_components > n_basis: - raise AttributeError("The number of components should be " - "smaller than the number of attributes of " - "target principal components' basis.") + raise AttributeError( + "The number of components should be " + "smaller than the number of attributes of " + "target principal components' basis.", + ) # if centering is True then subtract the mean function to each function # in FDataBasis @@ -148,15 +177,16 @@ def _fit_basis(self, X: FDataBasis, y=None): if components_basis is not None: # First fix domain range if not already done components_basis = components_basis.copy( - domain_range=X.basis.domain_range) + domain_range=X.basis.domain_range, + ) g_matrix = components_basis.gram_matrix() - # the matrix that are in charge of changing the computed principal + # The matrix that are in charge of changing the computed principal # components to target matrix is essentially the inner product # of both basis. j_matrix = X.basis.inner_product_matrix(components_basis) else: - # if no other basis is specified we use the same basis as the passed - # FDataBasis Object + # If no other basis is specified we use the same basis as the + # passed FDataBasis object components_basis = X.basis.copy() g_matrix = components_basis.gram_matrix() j_matrix = g_matrix @@ -168,10 +198,12 @@ def _fit_basis(self, X: FDataBasis, y=None): regularization_matrix = compute_penalty_matrix( basis_iterable=(components_basis,), regularization_parameter=1, - regularization=self.regularization) + regularization=self.regularization, + ) # apply regularization - g_matrix = (g_matrix + regularization_matrix) + if regularization_matrix is not None: + g_matrix = (g_matrix + regularization_matrix) # obtain triangulation using cholesky l_matrix = np.linalg.cholesky(g_matrix) @@ -180,12 +212,16 @@ def _fit_basis(self, X: FDataBasis, y=None): # using solve to get the multiplication result directly or just invert # the matrix. We choose solve because it is faster and more stable. # The following matrix is needed: L^{-1}*J^T - l_inv_j_t = solve_triangular(l_matrix, np.transpose(j_matrix), - lower=True) + l_inv_j_t = solve_triangular( + l_matrix, + np.transpose(j_matrix), + lower=True, + ) # the final matrix, C(L-1Jt)t for svd or (L-1Jt)-1CtC(L-1Jt)t for PCA - final_matrix = (X.coefficients @ np.transpose(l_inv_j_t) / - np.sqrt(n_samples)) + final_matrix = ( + X.coefficients @ np.transpose(l_inv_j_t) / np.sqrt(n_samples) + ) # initialize the pca module provided by scikit-learn pca = PCA(n_components=self.n_components) @@ -193,46 +229,56 @@ def _fit_basis(self, X: FDataBasis, y=None): # we choose solve to obtain the component coefficients for the # same reason: it is faster and more efficient - component_coefficients = solve_triangular(np.transpose(l_matrix), - np.transpose( - pca.components_), - lower=False) - - component_coefficients = np.transpose(component_coefficients) + component_coefficients = solve_triangular( + np.transpose(l_matrix), + np.transpose(pca.components_), + lower=False, + ) self.explained_variance_ratio_ = pca.explained_variance_ratio_ self.explained_variance_ = pca.explained_variance_ - self.components_ = X.copy(basis=components_basis, - coefficients=component_coefficients, - sample_names=(None,) * self.n_components) + self.components_ = X.copy( + basis=components_basis, + coefficients=component_coefficients.T, + sample_names=(None,) * self.n_components, + ) return self - def _transform_basis(self, X, y=None): - """Computes the n_components first principal components score and - returns them. + def _transform_basis( + self, + X: FDataBasis, + y: None = None, + ) -> np.ndarray: + """Compute the n_components first principal components score. Args: - X (FDataBasis): - the functional data object to be analysed - y (None, not used): - only present because of fit function convention + X: The functional data object to be analysed. + y: Ignored. Returns: - (array_like): the scores of the data with reference to the - principal components - """ + Principal component scores. + """ if X.basis != self._X_basis: - raise ValueError("The basis used in fit is different from " - "the basis used in transform.") + raise ValueError( + "The basis used in fit is different from " + "the basis used in transform.", + ) # in this case it is the inner product of our data with the components - return (X.coefficients @ self._j_matrix - @ self.components_.coefficients.T) + return ( + X.coefficients @ self._j_matrix + @ self.components_.coefficients.T + ) - def _fit_grid(self, X: FDataGrid, y=None): - r"""Computes the n_components first principal components and saves them. + def _fit_grid( + self, + X: FDataGrid, + y: None = None, + ) -> FPCA: + r""" + Compute the n_components first principal components and saves them. The eigenvalues associated with these principal components are also saved. For more details about how it is implemented @@ -246,31 +292,33 @@ def _fit_grid(self, X: FDataGrid, y=None): obtained using the trapezoidal rule. Args: - X (FDataGrid): - the functional data object to be analysed in basis - representation - y (None, not used): - only present for convention of a fit function + X: The functional data object to be analysed. + y: Ignored. Returns: - self (object) + self. References: .. [RS05-8-4-1] Ramsay, J., Silverman, B. W. (2005). Discretizing - the functions. In *Functional Data Analysis* (p. 161). Springer. - """ + the functions. In *Functional Data Analysis* (p. 161). + Springer. + """ # check that the number of components is smaller than the sample size if self.n_components > X.n_samples: - raise AttributeError("The sample size must be bigger than the " - "number of components") + raise AttributeError( + "The sample size must be bigger than the " + "number of components", + ) # check that we do not exceed limits for n_components as it should # be smaller than the number of attributes of the funcional data object if self.n_components > X.data_matrix.shape[1]: - raise AttributeError("The number of components should be " - "smaller than the number of discretization " - "points of the functional data object.") + raise AttributeError( + "The number of components should be " + "smaller than the number of discretization " + "points of the functional data object.", + ) # data matrix initialization fd_data = X.data_matrix.reshape(X.data_matrix.shape[:-1]) @@ -285,9 +333,9 @@ def _fit_grid(self, X: FDataGrid, y=None): # establish weights for each point of discretization if not self.weights: # grid_points is a list with one array in the 1D case - # in trapezoidal rule, suppose \deltax_k = x_k - x_{k-1}, the weight - # vector is as follows: [\deltax_1/2, \deltax_1/2 + \deltax_2/2, - # \deltax_2/2 + \deltax_3/2, ... , \deltax_n/2] + # in trapezoidal rule, suppose \deltax_k = x_k - x_{k-1}, the + # weight vector is as follows: [\deltax_1/2, \deltax_1/2 + + # \deltax_2/2, \deltax_2/2 + \deltax_3/2, ... , \deltax_n/2] differences = np.diff(X.grid_points[0]) differences = np.concatenate(((0,), differences, (0,))) self.weights = (differences[:-1] + differences[1:]) / 2 @@ -302,88 +350,109 @@ def _fit_grid(self, X: FDataGrid, y=None): basis = FDataGrid( data_matrix=np.identity(n_points_discretization), - grid_points=X.grid_points + grid_points=X.grid_points, ) regularization_matrix = compute_penalty_matrix( basis_iterable=(basis,), regularization_parameter=1, - regularization=self.regularization) + regularization=self.regularization, + ) - fd_data = np.transpose(np.linalg.solve( - np.transpose(basis.data_matrix[..., 0] + regularization_matrix), - np.transpose(fd_data))) + basis_matrix = basis.data_matrix[..., 0] + if regularization_matrix is not None: + basis_matrix = basis_matrix + regularization_matrix + + fd_data = np.linalg.solve( + basis_matrix.T, + fd_data.T, + ).T # see docstring for more information final_matrix = fd_data @ np.sqrt(weights_matrix) / np.sqrt(n_samples) pca = PCA(n_components=self.n_components) pca.fit(final_matrix) - self.components_ = X.copy(data_matrix=np.transpose( - np.linalg.solve(np.sqrt(weights_matrix), - np.transpose(pca.components_))), - sample_names=(None,) * self.n_components) + self.components_ = X.copy( + data_matrix=np.transpose( + np.linalg.solve( + np.sqrt(weights_matrix), + np.transpose(pca.components_), + ), + ), + sample_names=(None,) * self.n_components, + ) self.explained_variance_ratio_ = pca.explained_variance_ratio_ self.explained_variance_ = pca.explained_variance_ return self - def _transform_grid(self, X: FDataGrid, y=None): - """Computes the n_components first principal components score and - returns them. + def _transform_grid( + self, + X: FDataGrid, + y: None = None, + ) -> np.ndarray: + """ + Compute the ``n_components`` first principal components score. Args: - X (FDataGrid): - the functional data object to be analysed - y (None, not used): - only present because of fit function convention + X: The functional data object to be analysed. + y: Ignored. Returns: - (array_like): the scores of the data with reference to the - principal components - """ + Principal component scores. + """ # in this case its the coefficient matrix multiplied by the principal # components as column vectors - return X.data_matrix.reshape( - X.data_matrix.shape[:-1]) @ np.transpose( - self.components_.data_matrix.reshape( - self.components_.data_matrix.shape[:-1])) + return ( + X.data_matrix.reshape(X.data_matrix.shape[:-1]) + @ np.transpose( + self.components_.data_matrix.reshape( + self.components_.data_matrix.shape[:-1], + ), + ) + ) - def fit(self, X, y=None): - """Computes the n_components first principal components and saves them - inside the FPCA object, both FDataGrid and FDataBasis are accepted + def fit( + self, + X: FData, + y: None = None, + ) -> FPCA: + """ + Compute the n_components first principal components and saves them. Args: - X (FDataGrid or FDataBasis): - the functional data object to be analysed - y (None, not used): - only present for convention of a fit function + X: The functional data object to be analysed. + y: Ignored. Returns: - self (object) + self + """ if isinstance(X, FDataGrid): return self._fit_grid(X, y) elif isinstance(X, FDataBasis): return self._fit_basis(X, y) - else: - raise AttributeError("X must be either FDataGrid or FDataBasis") - def transform(self, X, y=None): - """Computes the n_components first principal components score and - returns them. + raise AttributeError("X must be either FDataGrid or FDataBasis") + + def transform( + self, + X: FData, + y: None = None, + ) -> np.ndarray: + """ + Compute the ``n_components`` first principal components scores. Args: - X (FDataGrid or FDataBasis): - the functional data object to be analysed - y (None, not used): - only present because of fit function convention + X: The functional data object to be analysed. + y: Only present because of fit function convention Returns: - (array_like): the scores of the data with reference to the - principal components + Principal component scores. + """ X = self._center_if_necessary(X, learn_mean=False) @@ -391,21 +460,23 @@ def transform(self, X, y=None): return self._transform_grid(X, y) elif isinstance(X, FDataBasis): return self._transform_basis(X, y) - else: - raise AttributeError("X must be either FDataGrid or FDataBasis") - def fit_transform(self, X, y=None, **fit_params): - """Computes the n_components first principal components and their scores - and returns them. + raise AttributeError("X must be either FDataGrid or FDataBasis") + + def fit_transform( + self, + X: FData, + y: None = None, + ) -> np.ndarray: + """ + Compute the n_components first principal components and their scores. + Args: - X (FDataGrid or FDataBasis): - the functional data object to be analysed - y (None, not used): - only present for convention of a fit function + X: The functional data object to be analysed. + y: Ignored Returns: - (array_like): the scores of the data with reference to the - principal components + Principal component scores. + """ - self.fit(X, y) - return self.transform(X, y) + return self.fit(X, y).transform(X, y) diff --git a/skfda/preprocessing/dim_reduction/projection/__init__.py b/skfda/preprocessing/dim_reduction/projection/__init__.py index 4b6cf980c..b6b3116cb 100644 --- a/skfda/preprocessing/dim_reduction/projection/__init__.py +++ b/skfda/preprocessing/dim_reduction/projection/__init__.py @@ -1 +1,8 @@ -from ._fpca import FPCA +import warnings + +from ..feature_extraction import FPCA + +warnings.warn( + 'The module "projection" is deprecated. Please use "feature_extraction"', + category=DeprecationWarning, +) diff --git a/skfda/preprocessing/dim_reduction/variable_selection/_rkvs.py b/skfda/preprocessing/dim_reduction/variable_selection/_rkvs.py index afd9119cc..e85b0cf76 100644 --- a/skfda/preprocessing/dim_reduction/variable_selection/_rkvs.py +++ b/skfda/preprocessing/dim_reduction/variable_selection/_rkvs.py @@ -1,28 +1,37 @@ -import sklearn.utils.validation +from __future__ import annotations + +from typing import Tuple import numpy as np import numpy.linalg as linalg +import sklearn.utils.validation +from ...._utils import _classifier_get_classes from ....representation import FDataGrid -def _rkhs_vs(X, Y, n_features_to_select: int=1): - ''' - Parameters - ---------- - X - Matrix of trajectories - Y - Vector of class labels - n_features_to_select - Number of selected features - ''' +def _rkhs_vs( + X: np.ndarray, + Y: np.ndarray, + n_features_to_select: int = 1, +) -> Tuple[np.ndarray, np.ndarray]: + """ + RKHS-VS implementation. + + Parameters: + X: Matrix of trajectories + Y: Vector of class labels + n_features_to_select: Number of selected features + + Returns: + Selected features and vector of scores. + """ X = np.atleast_2d(X) assert n_features_to_select >= 1 assert n_features_to_select <= X.shape[1] - Y = np.asarray(Y) + _, Y = _classifier_get_classes(Y) selected_features = np.zeros(n_features_to_select, dtype=int) score = np.zeros(n_features_to_select) @@ -32,8 +41,10 @@ def _rkhs_vs(X, Y, n_features_to_select: int=1): class_1_trajectories = X[Y.ravel() == 1] class_0_trajectories = X[Y.ravel() == 0] - means = (np.mean(class_1_trajectories, axis=0) - - np.mean(class_0_trajectories, axis=0)) + means = ( + np.mean(class_1_trajectories, axis=0) + - np.mean(class_0_trajectories, axis=0) + ) class_1_count = sum(Y) class_0_count = Y.shape[0] - class_1_count @@ -44,9 +55,12 @@ def _rkhs_vs(X, Y, n_features_to_select: int=1): # The result should be casted to 2D because of bug #11502 in numpy variances = ( class_1_proportion * np.atleast_2d( - np.cov(class_1_trajectories, rowvar=False, bias=True)) + - class_0_proportion * np.atleast_2d( - np.cov(class_0_trajectories, rowvar=False, bias=True))) + np.cov(class_1_trajectories, rowvar=False, bias=True), + ) + + class_0_proportion * np.atleast_2d( + np.cov(class_0_trajectories, rowvar=False, bias=True), + ) + ) # The first variable maximizes |mu(t)|/sigma(t) mu_sigma = np.abs(means) / np.sqrt(np.diag(variances)) @@ -59,14 +73,18 @@ def _rkhs_vs(X, Y, n_features_to_select: int=1): aux = np.zeros_like(indexes, dtype=np.float_) for j in range(0, indexes.shape[0]): - new_selection = np.concatenate([selected_features[0:i], - [indexes[j]]]) + new_selection = np.concatenate([ + selected_features[:i], + [indexes[j]], + ]) new_means = np.atleast_2d(means[new_selection]) lstsq_solution = linalg.lstsq( variances[new_selection[:, np.newaxis], new_selection], - new_means.T, rcond=None)[0] + new_means.T, + rcond=None, + )[0] aux[j] = new_means @ lstsq_solution @@ -78,9 +96,11 @@ def _rkhs_vs(X, Y, n_features_to_select: int=1): return selected_features, score -class RKHSVariableSelection(sklearn.base.BaseEstimator, - sklearn.base.TransformerMixin): - r''' +class RKHSVariableSelection( + sklearn.base.BaseEstimator, # type: ignore + sklearn.base.TransformerMixin, # type: ignore +): + r""" Reproducing kernel variable selection. This is a filter variable selection method for binary classification @@ -114,11 +134,9 @@ class RKHSVariableSelection(sklearn.base.BaseEstimator, a greedy approach, so this optimality is not always guaranteed. Parameters: - - n_features_to_select (int): number of features to select. + n_features_to_select: number of features to select. Examples: - >>> from skfda.preprocessing.dim_reduction import variable_selection >>> from skfda.datasets import make_gaussian_process >>> import skfda @@ -166,25 +184,30 @@ class RKHSVariableSelection(sklearn.base.BaseEstimator, (10000, 3) References: - .. [1] J. R. Berrendero, A. Cuevas, and J. L. Torrecilla, «On the Use of Reproducing Kernel Hilbert Spaces in Functional Classification», Journal of the American Statistical Association, vol. 113, no. 523, pp. 1210-1218, jul. 2018, doi: 10.1080/01621459.2017.1320287. - ''' + """ - def __init__(self, n_features_to_select: int=1): + def __init__(self, n_features_to_select: int = 1) -> None: self.n_features_to_select = n_features_to_select - def fit(self, X: FDataGrid, y): + def fit( # noqa: D102 + self, + X: FDataGrid, + y: np.ndarray, + ) -> RKHSVariableSelection: n_unique_labels = len(np.unique(y)) if n_unique_labels != 2: - raise ValueError(f"RK-VS can only be used when there are only " - f"two different labels, but there are " - f"{n_unique_labels}") + raise ValueError( + f"RK-VS can only be used when there are only " + f"two different labels, but there are " + f"{n_unique_labels}", + ) if X.dim_domain != 1 or X.dim_codomain != 1: raise ValueError("Domain and codomain dimensions must be 1") @@ -193,50 +216,57 @@ def fit(self, X: FDataGrid, y): self._features_shape_ = X.shape[1:] - self._features_, self._scores_ = _rkhs_vs( + features, scores = _rkhs_vs( X=X, Y=y, - n_features_to_select=self.n_features_to_select) + n_features_to_select=self.n_features_to_select, + ) + + self._features_ = features + self._scores_ = scores return self - def transform(self, X: FDataGrid, Y=None): + def transform( # noqa: D102 + self, + X: FDataGrid, + Y: None = None, + ) -> np.ndarray: sklearn.utils.validation.check_is_fitted(self) X_matrix = sklearn.utils.validation.check_array(X.data_matrix[..., 0]) if X_matrix.shape[1:] != self._features_shape_: - raise ValueError("The trajectories have a different number of " - "points than the ones fitted") + raise ValueError( + "The trajectories have a different number of " + "points than the ones fitted", + ) return X_matrix[:, self._features_] - def get_support(self, indices: bool=False): + def get_support(self, indices: bool = False) -> np.ndarray: """ - Get a mask, or integer index, of the features selected + Get a mask, or integer index, of the features selected. Parameters: - - indices : boolean (default False) - If True, the return value will be an array of integers, rather - than a boolean mask. + indices: If True, the return value will be an array of integers, + rather than a boolean mask. Returns: - support : array - An index that selects the retained features from a `FDataGrid` - object. - If `indices` is False, this is a boolean array of shape - [# input features], in which an element is True iff its - corresponding feature is selected for retention. If `indices` - is True, this is an integer array of shape [# output features] - whose values are indices into the input feature vector. + An index that selects the retained features from a `FDataGrid` + object. + If `indices` is False, this is a boolean array of shape + [# input features], in which an element is True iff its + corresponding feature is selected for retention. If `indices` + is True, this is an integer array of shape [# output features] + whose values are indices into the input feature vector. """ features = self._features_ if indices: return features - else: - mask = np.zeros(self._features_shape_[0], dtype=bool) - mask[features] = True - return mask + + mask = np.zeros(self._features_shape_[0], dtype=bool) + mask[features] = True + return mask diff --git a/skfda/preprocessing/dim_reduction/variable_selection/maxima_hunting.py b/skfda/preprocessing/dim_reduction/variable_selection/maxima_hunting.py index ff45b7e2d..74f2c35f4 100644 --- a/skfda/preprocessing/dim_reduction/variable_selection/maxima_hunting.py +++ b/skfda/preprocessing/dim_reduction/variable_selection/maxima_hunting.py @@ -1,20 +1,34 @@ -import dcor +"""Maxima Hunting dimensionality reduction and related methods.""" +from __future__ import annotations -import scipy.signal +from typing import Callable, Optional + +import numpy as np import sklearn.base import sklearn.utils -import numpy as np +import scipy.signal +from dcor import rowwise, u_distance_correlation_sqr from ....representation import FDataGrid +_DependenceMeasure = Callable[[np.ndarray, np.ndarray], np.ndarray] +_LocalMaximaSelector = Callable[[np.ndarray], np.ndarray] + + +def _compute_dependence( + X: np.ndarray, + y: np.ndarray, + *, + dependence_measure: _DependenceMeasure, +) -> np.ndarray: + """ + Compute dependence between points and target. -def _compute_dependence(X, y, *, dependence_measure): - ''' Computes the dependence of each point in each trajectory in X with the corresponding class label in Y. - ''' + """ # Move n_samples to the end # The shape is now input_shape + n_samples + n_output X = np.moveaxis(X, 0, -2) @@ -28,13 +42,13 @@ def _compute_dependence(X, y, *, dependence_measure): y = np.atleast_2d(y).T Y = np.array([y] * len(X)) - dependence_results = dcor.rowwise(dependence_measure, X, Y) + dependence_results = rowwise(dependence_measure, X, Y) return dependence_results.reshape(input_shape) -def select_local_maxima(X, *, order: int=1): - r''' +def select_local_maxima(X: np.ndarray, *, order: int = 1) -> np.ndarray: + r""" Compute local maxima of an array. Points near the boundary are considered maxima looking only at one side. @@ -43,13 +57,14 @@ def select_local_maxima(X, *, order: int=1): considered maxima. Parameters: - - X (numpy array): Where to compute the local maxima. - order (callable): How many points on each side to look, to check if + X: Where to compute the local maxima. + order: How many points on each side to look, to check if a point is a maximum in that interval. - Examples: + Returns: + Indexes of the local maxima. + Examples: >>> from skfda.preprocessing.dim_reduction.variable_selection.\ ... maxima_hunting import select_local_maxima >>> import numpy as np @@ -66,9 +81,12 @@ def select_local_maxima(X, *, order: int=1): >>> select_local_maxima(x, order=3).astype(np.int_) array([ 0, 5, 10]) - ''' + """ indexes = scipy.signal.argrelextrema( - X, comparator=np.greater_equal, order=order)[0] + X, + comparator=np.greater_equal, + order=order, + )[0] # Discard flat maxima = X[indexes] @@ -81,8 +99,11 @@ def select_local_maxima(X, *, order: int=1): return indexes[is_not_flat] -class MaximaHunting(sklearn.base.BaseEstimator, sklearn.base.TransformerMixin): - r''' +class MaximaHunting( + sklearn.base.BaseEstimator, # type: ignore + sklearn.base.TransformerMixin, # type: ignore +): + r""" Maxima Hunting variable selection. This is a filter variable selection method for problems with a target @@ -99,10 +120,9 @@ class MaximaHunting(sklearn.base.BaseEstimator, sklearn.base.TransformerMixin): For a longer explanation about the method, and comparison with other functional variable selection methods, we refer the reader to the - original article [1]_. + original article :footcite:`berrendero+cuevas+torrecilla_2016_hunting`. Parameters: - dependence_measure (callable): Dependence measure to use. By default, it uses the bias corrected squared distance correlation. local_maxima_selector (callable): Function to detect local maxima. The @@ -111,7 +131,6 @@ class MaximaHunting(sklearn.base.BaseEstimator, sklearn.base.TransformerMixin): different values of ``order``. Examples: - >>> from skfda.preprocessing.dim_reduction import variable_selection >>> from skfda.preprocessing.dim_reduction.variable_selection.\ ... maxima_hunting import select_local_maxima @@ -163,26 +182,26 @@ class MaximaHunting(sklearn.base.BaseEstimator, sklearn.base.TransformerMixin): (10000, 1) References: + .. footbibliography:: - .. [1] J. R. Berrendero, A. Cuevas, and J. L. Torrecilla, “Variable - selection in functional data classification: a maxima-hunting - proposal,” STAT SINICA, vol. 26, no. 2, pp. 619–638, 2016, - doi: 10.5705/ss.202014.0014. - - ''' + """ - def __init__(self, - dependence_measure=dcor.u_distance_correlation_sqr, - local_maxima_selector=select_local_maxima): + def __init__( + self, + dependence_measure: _DependenceMeasure = u_distance_correlation_sqr, + local_maxima_selector: _LocalMaximaSelector = select_local_maxima, + ) -> None: self.dependence_measure = dependence_measure self.local_maxima_selector = local_maxima_selector - def fit(self, X: FDataGrid, y): + def fit(self, X: FDataGrid, y: np.ndarray) -> MaximaHunting: # noqa: D102 self.features_shape_ = X.data_matrix.shape[1:] self.dependence_ = _compute_dependence( - X.data_matrix, y, - dependence_measure=self.dependence_measure) + X.data_matrix, + y, + dependence_measure=self.dependence_measure, + ) self.indexes_ = self.local_maxima_selector(self.dependence_) @@ -191,20 +210,26 @@ def fit(self, X: FDataGrid, y): return self - def get_support(self, indices: bool=False): + def get_support(self, indices: bool = False) -> np.ndarray: # noqa: D102 if indices: return self.indexes_ - else: - mask = np.zeros(self.features_shape_[0:-1], dtype=bool) - mask[self.indexes_] = True - return mask - def transform(self, X, y=None): + mask = np.zeros(self.features_shape_[:-1], dtype=bool) + mask[self.indexes_] = True + return mask + + def transform( # noqa: D102 + self, + X: FDataGrid, + y: Optional[np.ndarray] = None, + ) -> np.ndarray: sklearn.utils.validation.check_is_fitted(self) if X.data_matrix.shape[1:] != self.features_shape_: - raise ValueError("The trajectories have a different number of " - "points than the ones fitted") + raise ValueError( + "The trajectories have a different number of " + "points than the ones fitted", + ) return X.data_matrix[:, self.sorted_indexes_].reshape(X.n_samples, -1) diff --git a/skfda/preprocessing/dim_reduction/variable_selection/recursive_maxima_hunting.py b/skfda/preprocessing/dim_reduction/variable_selection/recursive_maxima_hunting.py index be3b5f22d..3c100e859 100644 --- a/skfda/preprocessing/dim_reduction/variable_selection/recursive_maxima_hunting.py +++ b/skfda/preprocessing/dim_reduction/variable_selection/recursive_maxima_hunting.py @@ -522,7 +522,8 @@ class AsymptoticIndependenceTestStop(StoppingCondition): Stop when the selected point is independent from the target. It uses an asymptotic test based on the chi-squared distribution described - in [1]_. The test rejects independence if + in :footcite:`szekely+rizzo_2010_brownian`. The test rejects independence + if .. math:: @@ -542,11 +543,7 @@ class AsymptoticIndependenceTestStop(StoppingCondition): default is 0.01 (1%). References: - - .. [1] G. J. Székely and M. L. Rizzo, “Brownian distance covariance,” - Ann. Appl. Stat., vol. 3, no. 4, pp. 1236–1265, Dec. 2009, - doi: 10.1214/09-AOAS312. - + .. footbibliography:: """ @@ -811,7 +808,8 @@ class RecursiveMaximaHunting( relevant once other points are selected. Those points would not be selected by :class:`MaximaHunting` alone. - This method was originally described in a special case in article [1]_. + This method was originally described in a special case in article + :footcite:`torrecilla+suarez_2016_hunting`. Additional information about the usage of this method can be found in :doc:`/modules/preprocessing/dim_reduction/recursive_maxima_hunting`. @@ -880,11 +878,7 @@ class RecursiveMaximaHunting( (1000, 3) References: - - .. [1] J. L. Torrecilla and A. Suárez, “Feature selection in - functional data classification with recursive maxima hunting,” - in Advances in Neural Information Processing Systems 29, - Curran Associates, Inc., 2016, pp. 4835–4843. + .. footbibliography:: """ diff --git a/skfda/preprocessing/registration/__init__.py b/skfda/preprocessing/registration/__init__.py index ce4a52cae..1894f4761 100644 --- a/skfda/preprocessing/registration/__init__.py +++ b/skfda/preprocessing/registration/__init__.py @@ -4,15 +4,13 @@ functional data, in basis as well in discretized form. """ -from ._landmark_registration import (landmark_shift_deltas, - landmark_shift, - landmark_registration_warping, - landmark_registration) - +from . import elastic, validation +from ._landmark_registration import ( + landmark_registration, + landmark_registration_warping, + landmark_shift, + landmark_shift_deltas, +) from ._shift_registration import ShiftRegistration - from ._warping import invert_warping, normalize_warping - from .elastic import ElasticRegistration - -from . import validation, elastic diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index 8f54466a3..2bdafaaa6 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -2,18 +2,27 @@ This module contains methods to perform the landmark registration. """ +from __future__ import annotations + +from typing import Callable, Optional, Sequence, Union import numpy as np -from ... import FDataGrid +from ...representation import FData, FDataGrid +from ...representation._typing import ArrayLike, GridPointsLike +from ...representation.extrapolation import ExtrapolationLike from ...representation.interpolation import SplineInterpolation -__author__ = "Pablo Marcos Manchón" -__email__ = "pablo.marcosm@estudiante.uam.es" +_FixedLocation = Union[float, Sequence[float]] +_LocationCallable = Callable[[np.ndarray], _FixedLocation] -def landmark_shift_deltas(fd, landmarks, location=None): - r"""Returns the corresponding shifts to align the landmarks of the curves. +def landmark_shift_deltas( + fd: FData, + landmarks: ArrayLike, + location: Union[_FixedLocation, _LocationCallable, None] = None, +) -> np.ndarray: + r"""Return the corresponding shifts to align the landmarks of the curves. Let :math:`t^*` the time where the landmarks of the curves will be aligned, and :math:`t_i` the location of the landmarks for each curve. @@ -24,10 +33,10 @@ def landmark_shift_deltas(fd, landmarks, location=None): :term:`domain` and the :term:`codomain`. Args: - fd (:class:`FData`): Functional data object. - landmarks (array_like): List with the landmarks of the samples. - location (numeric or callable, optional): Defines where - the landmarks will be alligned. If a numer or list is passed the + fd: Functional data object. + landmarks: List with the landmarks of the samples. + location: Defines where + the landmarks will be alligned. If a number or list is passed the landmarks will be alligned to it. In case of a callable is passed the location will be the result of the the call, the function should be accept as an unique parameter a numpy array @@ -37,14 +46,13 @@ def landmark_shift_deltas(fd, landmarks, location=None): max shift. Returns: - :class:`numpy.ndarray`: Array containing the corresponding shifts. + Array containing the corresponding shifts. Raises: ValueError: If the list of landmarks does not match with the number of samples. Examples: - >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples >>> from skfda.preprocessing.registration import landmark_shift_deltas @@ -67,32 +75,41 @@ def landmark_shift_deltas(fd, landmarks, location=None): FDataGrid(...) """ + landmarks = np.atleast_1d(landmarks) if len(landmarks) != fd.n_samples: - raise ValueError(f"landmark list ({len(landmarks)}) must have the same" - f" length than the number of samples ({fd.n_samples})") + raise ValueError( + f"landmark list ({len(landmarks)}) must have the same" + f" length than the number of samples ({fd.n_samples})", + ) - landmarks = np.atleast_1d(landmarks) + loc_array: Union[float, Sequence[float], np.ndarray] # Parses location if location is None: - p = (np.max(landmarks, axis=0) + np.min(landmarks, axis=0)) / 2. + loc_array = ( + np.max(landmarks, axis=0) + + np.min(landmarks, axis=0) + ) / 2 elif callable(location): - p = location(landmarks) + loc_array = location(landmarks) else: - try: - p = np.atleast_1d(location) - except: - raise ValueError("Invalid location, must be None, a callable or a " - "number in the domain") + loc_array = location - shifts = landmarks - p + loc_array = np.atleast_1d(loc_array) - return shifts + return landmarks - loc_array -def landmark_shift(fd, landmarks, location=None, *, restrict_domain=False, - extrapolation=None, eval_points=None, **kwargs): +def landmark_shift( + fd: FData, + landmarks: ArrayLike, + location: Union[_FixedLocation, _LocationCallable, None] = None, + *, + restrict_domain: bool = False, + extrapolation: Optional[ExtrapolationLike] = None, + grid_points: Optional[GridPointsLike] = None, +) -> FDataGrid: r"""Perform a shift of the curves to align the landmarks. Let :math:`t^*` the time where the landmarks of the curves will be @@ -105,9 +122,9 @@ def landmark_shift(fd, landmarks, location=None, *, restrict_domain=False, x_i^*(t^*)=x_i(t^* + \delta_i)=x_i(t_i) Args: - fd (:class:`FData`): Functional data object. - landmarks (array_like): List with the landmarks of the samples. - location (numeric or callable, optional): Defines where + fd: Functional data object. + landmarks: List with the landmarks of the samples. + location: Defines where the landmarks will be alligned. If a numeric value is passed the landmarks will be alligned to it. In case of a callable is passed the location will be the result of the the call, the @@ -116,22 +133,20 @@ def landmark_shift(fd, landmarks, location=None, *, restrict_domain=False, By default it will be used as location :math:`\frac{1}{2}(max( \text{landmarks})+ min(\text{landmarks}))` wich minimizes the max shift. - restrict_domain (bool, optional): If True restricts the domain to + restrict_domain: If True restricts the domain to avoid evaluate points outside the domain using extrapolation. Defaults uses extrapolation. - extrapolation (str or Extrapolation, optional): Controls the + extrapolation: Controls the extrapolation mode for elements outside the domain range. By default uses the method defined in fd. See extrapolation to more information. - eval_points (array_like, optional): Set of points where + grid_points: Grid of points where the functions are evaluated in :func:`shift`. - **kwargs: Keyword arguments to be passed to :func:`shift`. Returns: - :class:`FData`: Functional data object with the registered samples. + Functional data object with the registered samples. Examples: - >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples >>> from skfda.preprocessing.registration import landmark_shift @@ -148,16 +163,23 @@ def landmark_shift(fd, landmarks, location=None, *, restrict_domain=False, FDataGrid(...) """ - shifts = landmark_shift_deltas(fd, landmarks, location=location) - return fd.shift(shifts, restrict_domain=restrict_domain, - extrapolation=extrapolation, - eval_points=eval_points, **kwargs) - - -def landmark_registration_warping(fd, landmarks, *, location=None, - eval_points=None): + return fd.shift( + shifts, + restrict_domain=restrict_domain, + extrapolation=extrapolation, + grid_points=grid_points, + ) + + +def landmark_registration_warping( + fd: FData, + landmarks: ArrayLike, + *, + location: Optional[ArrayLike] = None, + grid_points: Optional[GridPointsLike] = None, +) -> FDataGrid: """Calculate the transformation used in landmark registration. Let :math:`t_{ij}` the time where the sample :math:`i` has the feature @@ -166,19 +188,21 @@ def landmark_registration_warping(fd, landmarks, *, location=None, :math:`h_i(t^*_j)=t_{ij}`. The registered samples can be obtained as :math:`x^*_i(t)=x_i(h_i(t))`. - See [RS05-7-3-1]_ for a detailed explanation. + See :footcite:`ramsay+silverman_2005_functional_landmark` + for a detailed explanation. Args: - fd (:class:`FData`): Functional data object. - landmarks (array_like): List containing landmarks for each samples. - location (array_like, optional): Defines where + fd: Functional data object. + landmarks: List containing landmarks for each samples. + location: Defines where the landmarks will be alligned. By default it will be used as location the mean of the landmarks. - eval_points (array_like, optional): Set of points where + grid_points: Grid of points where the functions are evaluated to obtain a discrete representation of the object. + Returns: - :class:`FDataGrid`: FDataGrid with the warpings function needed to + FDataGrid with the warpings function needed to register the functional data object. Raises: @@ -187,12 +211,9 @@ def landmark_registration_warping(fd, landmarks, *, location=None, the number of samples. References: - - .. [RS05-7-3-1] Ramsay, J., Silverman, B. W. (2005). Feature or landmark - registration. In *Functional Data Analysis* (pp. 132-136). Springer. + .. footbibliography:: Examples: - >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples >>> from skfda.preprocessing.registration import ( @@ -216,89 +237,106 @@ def landmark_registration_warping(fd, landmarks, *, location=None, >>> fd.compose(warping) FDataGrid(...) + """ + landmarks = np.asarray(landmarks) if fd.dim_domain > 1: - raise NotImplementedError("Method only implemented for objects with" - "domain dimension up to 1.") + raise NotImplementedError( + "Method only implemented for objects with " + "domain dimension up to 1.", + ) if len(landmarks) != fd.n_samples: - raise ValueError("The number of list of landmarks should be equal to " - "the number of samples") + raise ValueError( + "The number of list of landmarks should be equal to " + "the number of samples", + ) + + landmarks = landmarks.reshape((fd.n_samples, -1)) - landmarks = np.asarray(landmarks).reshape((fd.n_samples, -1)) + location = ( + np.mean(landmarks, axis=0) + if location is None + else np.asarray(location) + ) + + assert isinstance(location, np.ndarray) n_landmarks = landmarks.shape[-1] data_matrix = np.empty((fd.n_samples, n_landmarks + 2)) - data_matrix[:, 0] = fd.domain_range[0][0] data_matrix[:, -1] = fd.domain_range[0][1] - data_matrix[:, 1:-1] = landmarks - if location is None: - grid_points = np.mean(data_matrix, axis=0) - - elif n_landmarks != len(location): + if n_landmarks == len(location): + if grid_points is None: + grid_points = np.empty(n_landmarks + 2) + grid_points[0] = fd.domain_range[0][0] + grid_points[-1] = fd.domain_range[0][1] + grid_points[1:-1] = location - raise ValueError(f"Number of landmark locations should be equal than " - f"the number of landmarks ({len(location)}) != " - f"({n_landmarks})") else: - grid_points = np.empty(n_landmarks + 2) - grid_points[0] = fd.domain_range[0][0] - grid_points[-1] = fd.domain_range[0][1] - grid_points[1:-1] = location + raise ValueError( + f"Number of landmark locations should be equal than " + f"the number of landmarks ({len(location)}) != ({n_landmarks})", + ) interpolation = SplineInterpolation(interpolation_order=3, monotone=True) - warping = FDataGrid(data_matrix=data_matrix, - grid_points=grid_points, - interpolation=interpolation, - extrapolation='bounds') + warping = FDataGrid( + data_matrix=data_matrix, + grid_points=grid_points, + interpolation=interpolation, + extrapolation='bounds', + ) try: warping_points = fd.grid_points except AttributeError: - warping_points = [np.linspace(*domain, 201) - for domain in fd.domain_range] + warping_points = None return warping.to_grid(warping_points) -def landmark_registration(fd, landmarks, *, location=None, eval_points=None): - """Perform landmark registration of the curves. +def landmark_registration( + fd: FData, + landmarks: ArrayLike, + *, + location: Optional[ArrayLike] = None, + grid_points: Optional[GridPointsLike] = None, +) -> FDataGrid: + """ + Perform landmark registration of the curves. - Let :math:`t_{ij}` the time where the sample :math:`i` has the feature - :math:`j` and :math:`t^*_j` the new time for the feature. - The registered samples will have their features aligned, i.e., - :math:`x^*_i(t^*_j)=x_i(t_{ij})`. + Let :math:`t_{ij}` the time where the sample :math:`i` has the feature + :math:`j` and :math:`t^*_j` the new time for the feature. + The registered samples will have their features aligned, i.e., + :math:`x^*_i(t^*_j)=x_i(t_{ij})`. - See [RS05-7-3]_ for a detailed explanation. + See :footcite:`ramsay+silverman_2005_functional_landmark` + for a detailed explanation. Args: - fd (:class:`FData`): Functional data object. - landmarks (array_like): List containing landmarks for each samples. - location (array_like, optional): Defines where + fd: Functional data object. + landmarks: List containing landmarks for each samples. + location: Defines where the landmarks will be alligned. By default it will be used as location the mean of the landmarks. - eval_points (array_like, optional): Set of points where + grid_points: Grid of points where the functions are evaluated to obtain a discrete representation of the object. In case of objects with multidimensional :term:`domain` a list axis with points of evaluation for each dimension. Returns: - :class:`FData`: FData with the functional data object registered. + FDataGrid with the functional data object registered. References: - - .. [RS05-7-3] Ramsay, J., Silverman, B. W. (2005). Feature or landmark - registration. In *Functional Data Analysis* (pp. 132-136). Springer. + .. footbibliography:: Examples: - >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples >>> from skfda.preprocessing.registration import landmark_registration @@ -321,11 +359,14 @@ def landmark_registration(fd, landmarks, *, location=None, eval_points=None): >>> fd = fd.to_basis(BSpline(n_basis=12)) >>> landmark_registration(fd, landmarks) - FDataBasis(...) + FDataGrid(...) """ - - warping = landmark_registration_warping(fd, landmarks, location=location, - eval_points=eval_points) - - return fd.compose(warping) + warping = landmark_registration_warping( + fd, + landmarks, + location=location, + grid_points=grid_points, + ) + + return fd.to_grid(grid_points).compose(warping) diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 19aae9f6c..0416e2e49 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -1,24 +1,32 @@ """Class to apply Shift Registration to functional data""" +from __future__ import annotations -# Pablo Marcos Manchón -# pablo.marcosm@protonmail.com +from typing import Callable, Optional, Tuple, TypeVar, Union import numpy as np -from scipy.integrate import simps from sklearn.utils.validation import check_is_fitted +from typing_extensions import Literal from ... import FData, FDataGrid -from ..._utils import check_is_univariate, constants +from ..._utils import check_is_univariate +from ...misc._math import inner_product +from ...misc.metrics._lp_norms import l2_norm +from ...representation._typing import ArrayLike, GridPointsLike +from ...representation.extrapolation import ExtrapolationLike from .base import RegistrationTransformer +T = TypeVar("T", bound=FData) +TemplateFunction = Callable[[FDataGrid], FDataGrid] + class ShiftRegistration(RegistrationTransformer): r"""Register a functional dataset using shift alignment. Realizes the registration of a set of curves using a shift aligment - [RaSi2005-7-2]_. Let :math:`\{x_i(t)\}_{i=1}^{N}` be a functional dataset, - calculates :math:`\delta_{i}` for each sample such that - :math:`x_i(t + \delta_{i})` minimizes the least squares criterion: + :footcite:`ramsay+silverman_2005_functional_shift`. + Let :math:`\{x_i(t)\}_{i=1}^{N}` be a functional dataset, calculates + :math:`\delta_{i}` for each sample such that :math:`x_i(t + \delta_{i})` + minimizes the least squares criterion: .. math:: \text{REGSSE} = \sum_{i=1}^{N} \int_{\mathcal{T}} @@ -27,18 +35,18 @@ class ShiftRegistration(RegistrationTransformer): Estimates each shift parameter :math:`\delta_i` iteratively by using a modified Newton-Raphson algorithm, updating the template :math:`\mu` in each iteration as is described in detail in - [RaSi2005-7-9-1]_. + :footcite:`ramsay+silverman_2005_functional_newton-raphson`. Method only implemented for univariate functional data. Args: - max_iter (int, optional): Maximun number of iterations. + max_iter: Maximun number of iterations. Defaults sets to 5. Generally 2 or 3 iterations are sufficient to obtain a good alignment. - tol (float, optional): Tolerance allowable. The process will stop if + tol: Tolerance allowable. The process will stop if :math:`\max_{i}|\delta_{i}^{(\nu)}-\delta_{i}^{(\nu-1)}|>> from skfda.preprocessing.registration import ShiftRegistration >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.basis import Fourier @@ -100,31 +109,37 @@ class ShiftRegistration(RegistrationTransformer): Shifts applied during the transformation >>> reg.deltas_.round(3) - array([-0.128, 0.187, 0.027, 0.034, -0.106, 0.114, ..., -0.06 ]) + array([-0.131, 0.188, 0.026, 0.033, -0.109, 0.115, ..., -0.062]) - Registration and creation of a dataset in basis form using the - transformation previosly fitted: + Registration of a dataset in basis form using the + transformation previosly fitted. The result is a dataset in + discretized form, as it is not possible to express shifted functions + exactly as a basis expansion: >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, ... random_state=2) >>> fd_basis = fd.to_basis(Fourier()) >>> reg.transform(fd_basis) - FDataBasis(...) + FDataGrid(...) References: - .. [RaSi2005-7-2] Ramsay, J., Silverman, B. W. (2005). Shift - registration. In *Functional Data Analysis* (pp. 129-132). - Springer. - .. [RaSi2005-7-9-1] Ramsay, J., Silverman, B. W. (2005). Shift - registration by the Newton-Raphson algorithm. In *Functional - Data Analysis* (pp. 142-144). Springer. + .. footbibliography:: + """ - def __init__(self, max_iter=5, tol=1e-2, template="mean", - extrapolation=None, step_size=1, restrict_domain=False, - initial="zeros", output_points=None): + def __init__( + self, + max_iter: int = 5, + tol: float = 1e-2, + template: Union[Literal["mean"], FData, TemplateFunction] = "mean", + extrapolation: Optional[ExtrapolationLike] = None, + step_size: float = 1, + restrict_domain: bool = False, + initial: Union[Literal["zeros"], ArrayLike] = "zeros", + grid_points: Optional[GridPointsLike] = None, + ) -> None: self.max_iter = max_iter self.tol = tol self.template = template @@ -132,21 +147,24 @@ def __init__(self, max_iter=5, tol=1e-2, template="mean", self.extrapolation = extrapolation self.step_size = step_size self.initial = initial - self.output_points = output_points + self.grid_points = grid_points - def _compute_deltas(self, fd, template): - r"""Compute the shifts to perform the registration. + def _compute_deltas( + self, + fd: FData, + template: Union[Literal["mean"], FData, TemplateFunction], + ) -> Tuple[np.ndarray, FDataGrid]: + """Compute the shifts to perform the registration. Args: - fd (FData: Functional object to be registered. - template (str, FData or callable): Template to align the + fd: Functional object to be registered. + template: Template to align the the samples. "mean" to compute the mean iteratively as in the original paper, an FData with the templated calculated or a callable wich constructs the template. Returns: - tuple: A tuple with an array of deltas and an FDataGrid with the - template. + A tuple with an array of deltas and an FDataGrid with the template. """ check_is_univariate(fd) @@ -156,222 +174,188 @@ def _compute_deltas(self, fd, template): # Initial estimation of the shifts if self.initial == "zeros": delta = np.zeros(fd.n_samples) - - elif len(self.initial) != fd.n_samples: - raise ValueError(f"the initial shift ({len(self.initial)}) must " - f"have the same length than the number of samples" - f" ({fd.n_samples})") else: delta = np.asarray(self.initial) - # Fine equispaced mesh to evaluate the samples - if self.output_points is None: - - try: - output_points = fd.grid_points[0] - nfine = len(output_points) - except AttributeError: - nfine = max(fd.n_basis * constants.BASIS_MIN_FACTOR + 1, - constants.N_POINTS_COARSE_MESH) - output_points = np.linspace(*domain_range, nfine) - - else: - nfine = len(self.output_points) - output_points = np.asarray(self.output_points) + if len(delta) != fd.n_samples: + raise ValueError( + f"The length of the initial shift ({len(delta)}) must " + f"be the same than the number of samples ({fd.n_samples})", + ) # Auxiliar array to avoid multiple memory allocations delta_aux = np.empty(fd.n_samples) # Computes the derivate of originals curves in the mesh points - fd_deriv = fd.derivative(order=1) - D1x = fd_deriv(output_points)[..., 0] + fd_deriv = fd.derivative() # Second term of the second derivate estimation of REGSSE. The # first term has been dropped to improve convergence (see references) - d2_regsse = simps(np.square(D1x), output_points, axis=1) + d2_regsse = l2_norm(fd_deriv)**2 + + # We need the discretized derivative to compute the inner product later + fd_deriv = fd_deriv.to_grid(grid_points=self.grid_points) max_diff = self.tol + 1 self.n_iter_ = 0 - # Case template fixed - if isinstance(template, FData): - original_template = template - tfine_aux = template.evaluate(output_points)[0, ..., 0] - - if self.restrict_domain: - template_points_aux = tfine_aux - - template = "fixed" - else: - tfine_aux = np.empty(nfine) - - # Auxiliar array if the domain will be restricted - if self.restrict_domain: - D1x_tmp = D1x - tfine_tmp = output_points - tfine_aux_tmp = tfine_aux - domain = np.empty(nfine, dtype=np.dtype(bool)) - - ones = np.ones(fd.n_samples) - output_points_rep = np.outer(ones, output_points) - # Newton-Rhapson iteration while max_diff > self.tol and self.n_iter_ < self.max_iter: + # Computes the new values shifted + x = fd.shift(delta, grid_points=self.grid_points) + + if isinstance(template, str): + assert template == "mean" + template_iter = x.mean() + elif isinstance(template, FData): + template_iter = template.to_grid(grid_points=x.grid_points) + else: # Callable + template_iter = template(x) + # Updates the limits for non periodic functions ignoring the ends if self.restrict_domain: # Calculates the new limits a = domain_range[0] - min(np.min(delta), 0) b = domain_range[1] - max(np.max(delta), 0) - # New interval is (a,b) - np.logical_and(tfine_tmp >= a, tfine_tmp <= b, out=domain) - output_points = tfine_tmp[domain] - tfine_aux = tfine_aux_tmp[domain] - D1x = D1x_tmp[:, domain] - # Reescale the second derivate could be other approach - # d2_regsse = - # d2_regsse_original * ( 1 + (a - b) / (domain[1] - domain[0])) - d2_regsse = simps(np.square(D1x), output_points, axis=1) + restricted_domain = ( + max(a, template_iter.domain_range[0][0]), + min(b, template_iter.domain_range[0][1]), + ) - # Recompute base points for evaluation - output_points_rep = np.outer(ones, output_points) + template_iter = template_iter.restrict(restricted_domain) - # Computes the new values shifted - x = fd(output_points_rep + np.atleast_2d(delta).T, - aligned=False, - extrapolation=self.extrapolation)[..., 0] - - if template == "mean": - x.mean(axis=0, out=tfine_aux) - elif template == "fixed" and self.restrict_domain: - tfine_aux = template_points_aux[domain] - elif callable(template): # Callable - fd_x = FDataGrid(x, grid_points=output_points) - fd_tfine = template(fd_x) - tfine_aux = fd_tfine.data_matrix.ravel() + x = x.restrict(restricted_domain) + fd_deriv = fd_deriv.restrict(restricted_domain) + d2_regsse = l2_norm(fd_deriv)**2 # Calculates x - mean - np.subtract(x, tfine_aux, out=x) + x -= template_iter + + d1_regsse = inner_product(x, fd_deriv) - d1_regsse = simps(np.multiply(x, D1x, out=x), - output_points, axis=1) # Updates the shifts by the Newton-Rhapson iteration - # delta = delta - step_size * d1_regsse / d2_regsse - np.divide(d1_regsse, d2_regsse, out=delta_aux) - np.multiply(delta_aux, self.step_size, out=delta_aux) - np.subtract(delta, delta_aux, out=delta) + # Same as delta = delta - step_size * d1_regsse / d2_regsse + delta_aux[:] = d1_regsse + delta_aux[:] /= d2_regsse + delta_aux[:] *= self.step_size + delta[:] -= delta_aux # Updates convergence criterions max_diff = np.abs(delta_aux, out=delta_aux).max() self.n_iter_ += 1 - if template == "fixed": - - # Stores the original template instead of building it again - template = original_template - else: - - # Stores the template in an FDataGrid - template = FDataGrid(tfine_aux, grid_points=output_points) - - return delta, template + return delta, template_iter - def fit_transform(self, X: FData, y=None): + def fit_transform(self, X: T, y: None = None) -> T: """Fit the estimator and transform the data. Args: - X (FData): Functional dataset to be transformed. - y (ignored): not used, present for API consistency by convention. + X: Functional dataset to be transformed. + y: not used, present for API consistency by convention. Returns: - FData: Functional data registered. + Functional data registered. """ - self.deltas_, self.template_ = self._compute_deltas(X, self.template) + deltas, template = self._compute_deltas(X, self.template) + + self.deltas_ = deltas + self.template_ = template - return X.shift(self.deltas_, restrict_domain=self.restrict_domain, - extrapolation=self.extrapolation, - eval_points=self.output_points) + return X.shift( + self.deltas_, + restrict_domain=self.restrict_domain, + extrapolation=self.extrapolation, + grid_points=self.grid_points, + ) - def fit(self, X: FData, y=None): + def fit(self, X: FData, y: None = None) -> ShiftRegistration: """Fit the estimator. Args: - X (FData): Functional dataset used to construct the template for + X: Functional dataset used to construct the template for the alignment. - y (ignored): not used, present for API consistency by convention. + y: not used, present for API consistency by convention. Returns: - RegistrationTransformer: self + self Raises: AttributeError: If this method is call when restrict_domain=True. """ if self.restrict_domain: - raise AttributeError("fit and predict are not available when " - "restrict_domain=True, fitting and " - "transformation should be done together. Use " - "an extrapolation method with " - "restrict_domain=False or fit_predict") + raise AttributeError( + "fit and predict are not available when " + "restrict_domain=True, fitting and " + "transformation should be done together. Use " + "an extrapolation method with " + "restrict_domain=False or fit_predict", + ) # If the template is an FData, fit doesnt learn anything if isinstance(self.template, FData): self.template_ = self.template else: - _, self.template_ = self._compute_deltas(X, self.template) + _, template = self._compute_deltas(X, self.template) + + self.template_ = template return self - def transform(self, X: FData, y=None): + def transform(self, X: FData, y: None = None) -> FDataGrid: """Register the data. Transforms the data using the template previously learned during fitting. Args: - X (FData): Functional dataset to be transformed. - y (ignored): not used, present for API consistency by convention. + X: Functional dataset to be transformed. + y: not used, present for API consistency by convention. Returns: - FData: Functional data registered. + Functional data registered. Raises: AttributeError: If this method is call when restrict_domain=True. """ - if self.restrict_domain: - raise AttributeError("fit and predict are not available when " - "restrict_domain=True, fitting and " - "transformation should be done together. Use " - "an extrapolation method with " - "restrict_domain=False or fit_predict") + raise AttributeError( + "fit and predict are not available when " + "restrict_domain=True, fitting and " + "transformation should be done together. Use " + "an extrapolation method with " + "restrict_domain=False or fit_predict", + ) # Check is fitted - check_is_fitted(self, 'template_') + check_is_fitted(self) - deltas, template = self._compute_deltas(X, self.template_) - self.template_ = template + deltas, _ = self._compute_deltas(X, self.template_) self.deltas_ = deltas - return X.shift(deltas, restrict_domain=self.restrict_domain, - extrapolation=self.extrapolation, - eval_points=self.output_points) + return X.shift( + deltas, + restrict_domain=self.restrict_domain, + extrapolation=self.extrapolation, + grid_points=self.grid_points, + ) - def inverse_transform(self, X: FData, y=None): + def inverse_transform(self, X: FData, y: None = None) -> FDataGrid: """Applies the inverse transformation. Applies the opossite shift used in the last call to `transform`. Args: - X (FData): Functional dataset to be transformed. - y (ignored): not used, present for API consistency by convention. + X: Functional dataset to be transformed. + y: not used, present for API consistency by convention. Returns: - FData: Functional data registered. + Functional data registered. Examples: @@ -394,13 +378,22 @@ def inverse_transform(self, X: FData, y=None): FDataGrid(...) """ - if not hasattr(self, "deltas_"): - raise AttributeError("Data must be previously transformed to learn" - " the inverse transformation") - elif len(X) != len(self.deltas_): - raise ValueError("Data must contain the same number of samples " - "than the dataset previously transformed") - - return X.shift(-self.deltas_, restrict_domain=self.restrict_domain, - extrapolation=self.extrapolation, - eval_points=self.output_points) + deltas = getattr(self, "deltas_", None) + + if deltas is None: + raise AttributeError( + "Data must be previously transformed to learn" + " the inverse transformation", + ) + elif len(X) != len(deltas): + raise ValueError( + "Data must contain the same number of samples " + "than the dataset previously transformed", + ) + + return X.shift( + -deltas, + restrict_domain=self.restrict_domain, + extrapolation=self.extrapolation, + grid_points=self.grid_points, + ) diff --git a/skfda/preprocessing/registration/_warping.py b/skfda/preprocessing/registration/_warping.py index 35fc7ba86..00f8b3d75 100644 --- a/skfda/preprocessing/registration/_warping.py +++ b/skfda/preprocessing/registration/_warping.py @@ -2,21 +2,23 @@ This module contains routines related to the registration procedure. """ -import collections -import scipy.integrate -from scipy.interpolate import PchipInterpolator +from typing import Optional import numpy as np -from ..._utils import check_is_univariate - +from scipy.interpolate import PchipInterpolator -__author__ = "Pablo Marcos Manchón" -__email__ = "pablo.marcosm@estudiante.uam.es" +from ..._utils import _to_domain_range, check_is_univariate +from ...representation import FDataGrid +from ...representation._typing import ArrayLike, DomainRangeLike -def invert_warping(fdatagrid, *, output_points=None): +def invert_warping( + warping: FDataGrid, + *, + output_points: Optional[ArrayLike] = None, +) -> FDataGrid: r"""Compute the inverse of a diffeomorphism. Let :math:`\gamma : [a,b] \rightarrow [a,b]` be a function strictly @@ -27,20 +29,19 @@ def invert_warping(fdatagrid, *, output_points=None): Uses a PCHIP interpolator to compute approximately the inverse. Args: - fdatagrid (:class:`FDataGrid`): Functions to be inverted. - eval_points: (array_like, optional): Set of points where the + warping: Functions to be inverted. + output_points: Set of points where the functions are interpolated to obtain the inverse, by default uses the sample points of the fdatagrid. Returns: - :class:`FDataGrid`: Inverse of the original functions. + Inverse of the original functions. Raises: ValueError: If the functions are not strictly increasing or are multidimensional. Examples: - >>> import numpy as np >>> from skfda import FDataGrid >>> from skfda.preprocessing.registration import invert_warping @@ -71,34 +72,36 @@ def invert_warping(fdatagrid, *, output_points=None): [ 1. ]]]) """ + check_is_univariate(warping) - check_is_univariate(fdatagrid) - - if output_points is None: - output_points = fdatagrid.grid_points[0] + output_points = ( + warping.grid_points[0] + if output_points is None + else np.asarray(output_points) + ) - y = fdatagrid(output_points)[..., 0] + y = warping(output_points)[..., 0] - data_matrix = np.empty((fdatagrid.n_samples, len(output_points))) + data_matrix = np.empty((warping.n_samples, len(output_points))) - for i in range(fdatagrid.n_samples): + for i in range(warping.n_samples): data_matrix[i] = PchipInterpolator(y[i], output_points)(output_points) - return fdatagrid.copy(data_matrix=data_matrix, grid_points=output_points) + return warping.copy(data_matrix=data_matrix, grid_points=output_points) -def _normalize_scale(t, a=0, b=1): +def _normalize_scale(t: np.ndarray, a: float = 0, b: float = 1) -> np.ndarray: """Perfoms an afine translation to normalize an interval. Args: - t (numpy.ndarray): Array of dim 1 or 2 with at least 2 values. - a (float): Starting point of the new interval. Defaults 0. - b (float): Stopping point of the new interval. Defaults 1. + t: Array of dim 1 or 2 with at least 2 values. + a: Starting point of the new interval. Defaults 0. + b: Stopping point of the new interval. Defaults 1. Returns: - (numpy.ndarray): Array with the transformed interval. - """ + Array with the transformed interval. + """ t = t.T # Broadcast to normalize multiple arrays t1 = (t - t[0]).astype(float) # Translation to [0, t[-1] - t[0]] t1 *= (b - a) / (t[-1] - t[0]) # Scale to [0, b-a] @@ -109,7 +112,10 @@ def _normalize_scale(t, a=0, b=1): return t1.T -def normalize_warping(warping, domain_range=None): +def normalize_warping( + warping: FDataGrid, + domain_range: Optional[DomainRangeLike] = None, +) -> FDataGrid: r"""Rescale a warping to normalize their :term:`domain`. Given a set of warpings :math:`\gamma_i:[a,b]\rightarrow [a,b]` it is @@ -118,19 +124,28 @@ def normalize_warping(warping, domain_range=None): [\tilde a, \tilde b]`. Args: - warping (:class:`FDatagrid`): Set of warpings to rescale. - domain_range (tuple, optional): New domain range of the warping. By + warping: Set of warpings to rescale. + domain_range: New domain range of the warping. By default it is used the same domain range. - Return: - (:class:`FDataGrid`): FDataGrid with the warpings normalized. - - """ - if domain_range is None: - domain_range = warping.domain_range[0] - - data_matrix = _normalize_scale(warping.data_matrix[..., 0], *domain_range) - grid_points = _normalize_scale(warping.grid_points[0], *domain_range) + Returns: + Normalized warpings. - return warping.copy(data_matrix=data_matrix, grid_points=grid_points, - domain_range=domain_range) + """ + domain_range_tuple = ( + warping.domain_range[0] + if domain_range is None + else _to_domain_range(domain_range)[0] + ) + + data_matrix = _normalize_scale( + warping.data_matrix[..., 0], + *domain_range_tuple, + ) + grid_points = _normalize_scale(warping.grid_points[0], *domain_range_tuple) + + return warping.copy( + data_matrix=data_matrix, + grid_points=grid_points, + domain_range=domain_range, + ) diff --git a/skfda/preprocessing/registration/base.py b/skfda/preprocessing/registration/base.py index a705c52a0..8187cf670 100644 --- a/skfda/preprocessing/registration/base.py +++ b/skfda/preprocessing/registration/base.py @@ -1,17 +1,26 @@ # -*- coding: utf-8 -*- -"""Registration method. +"""Registration methods base class. + This module contains the abstract base class for all registration methods. + """ from abc import ABC + from sklearn.base import BaseEstimator, TransformerMixin + from ... import FData -class RegistrationTransformer(ABC, BaseEstimator, TransformerMixin): + +class RegistrationTransformer( + ABC, + BaseEstimator, # type: ignore + TransformerMixin, # type: ignore +): """Base class for the registration methods.""" - def score(self, X: FData, y=None): - r"""Returns the percentage of total variation removed. + def score(self, X: FData, y: None = None) -> float: + r"""Return the percentage of total variation removed. Computes the squared multiple correlation index of the proportion of the total variation due to phase, defined as: diff --git a/skfda/preprocessing/registration/elastic.py b/skfda/preprocessing/registration/elastic.py index 03e0de7a2..c90280c85 100644 --- a/skfda/preprocessing/registration/elastic.py +++ b/skfda/preprocessing/registration/elastic.py @@ -1,30 +1,32 @@ -from fdasrsf.utility_functions import optimum_reparam -import scipy.integrate +from __future__ import annotations + +from typing import Callable, Optional, Union + +import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -import numpy as np +import scipy.integrate +from fdasrsf.utility_functions import optimum_reparam -from . import invert_warping from ... import FDataGrid from ..._utils import check_is_univariate +from ...representation._typing import ArrayLike from ...representation.interpolation import SplineInterpolation -from ._warping import _normalize_scale +from ._warping import _normalize_scale, invert_warping from .base import RegistrationTransformer - -__author__ = "Pablo Marcos Manchón" -__email__ = "pablo.marcosm@estudiante.uam.es" - ############################################################################### # Based on the original implementation of J. Derek Tucker in # # *fdasrsf_python* (https://github.com/jdtuck/fdasrsf_python) # # and *ElasticFDA.jl* (https://github.com/jdtuck/ElasticFDA.jl). # ############################################################################### +_MeanType = Callable[[FDataGrid], FDataGrid] -class SRSF(BaseEstimator, TransformerMixin): + +class SRSF(BaseEstimator, TransformerMixin): # type: ignore r"""Square-Root Slope Function (SRSF) transform. Let :math:`f : [a,b] \rightarrow \mathbb{R}` be an absolutely continuous @@ -36,7 +38,7 @@ class SRSF(BaseEstimator, TransformerMixin): This representation it is used to compute the extended non-parametric Fisher-Rao distance between functions, wich under the SRSF representation becomes the usual :math:`\mathbb{L}^2` distance between functions. - See [SK16-4-6]_ . + See :footcite:`srivastava+klassen_2016_analysis_square`. The inverse SRSF transform is defined as @@ -49,11 +51,20 @@ class SRSF(BaseEstimator, TransformerMixin): which is dropped due to derivation. If it is applied the inverse transformation without fit the estimator it is assumed that :math:`f(a)=0`. + Args: + eval_points: (array_like, optional): Set of points where the + functions are evaluated, by default uses the sample points of + the :class:`FDataGrid ` transformed. + initial_value (float, optional): Initial value to apply in the + inverse transformation. If `None` there are stored the initial + values of the functions during the transformation to apply + during the inverse transformation. Defaults None. + Attributes: - eval_points (array_like, optional): Set of points where the - functions are evaluated, by default uses the sample points of the + eval_points: Set of points where the + functions are evaluated, by default uses the grid points of the fdatagrid. - initial_value (float, optional): Initial value to apply in the + initial_value: Initial value to apply in the inverse transformation. If `None` there are stored the initial values of the functions during the transformation to apply during the inverse transformation. Defaults None. @@ -64,12 +75,9 @@ class SRSF(BaseEstimator, TransformerMixin): in order to achieve good results. References: - .. [SK16-4-6] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Square-Root Slope Function - Representation* (pp. 91-93). Springer. + .. footbibliography:: Examples: - Create a toy dataset and apply the transformation and its inverse. >>> from skfda.datasets import make_sinusoidal_process @@ -95,28 +103,21 @@ class SRSF(BaseEstimator, TransformerMixin): """ - def __init__(self, output_points=None, initial_value=None): - """Initializes the transformer. - - Args: - eval_points: (array_like, optional): Set of points where the - functions are evaluated, by default uses the sample points of - the :class:`FDataGrid ` transformed. - initial_value (float, optional): Initial value to apply in the - inverse transformation. If `None` there are stored the initial - values of the functions during the transformation to apply - during the inverse transformation. Defaults None. - - """ + def __init__( + self, + output_points: Optional[ArrayLike] = None, + initial_value: Optional[float] = None, + ) -> None: self.output_points = output_points self.initial_value = initial_value - def fit(self, X=None, y=None): - """This transformer do not need to be fitted. + def fit(self, X: FDataGrid, y: None = None) -> SRSF: + """ + Return self. This transformer does not need to be fitted. Args: - X (Ignored): Present for API conventions. - y (Ignored): Present for API conventions. + X: Present for API conventions. + y: Present for API conventions. Returns: (Estimator): self @@ -124,38 +125,34 @@ def fit(self, X=None, y=None): """ return self - def transform(self, X: FDataGrid, y=None): - r"""Computes the square-root slope function (SRSF) transform. + def transform(self, X: FDataGrid, y: None = None) -> FDataGrid: + r"""Compute the square-root slope function (SRSF) transform. - Let :math:`f : [a,b] \rightarrow \mathbb{R}` be an absolutely continuous - function, the SRSF transform is defined as [SK16-4-6-1]_: + Let :math:`f : [a,b] \rightarrow \mathbb{R}` be an absolutely + continuous function, the SRSF transform is defined as + :footcite:`srivastava+klassen_2016_analysis_square`: .. math:: SRSF(f(t)) = sgn(f(t)) \sqrt{\dot f(t)|} = q(t) Args: - X (:class:`FDataGrid`): Functions to be transformed. - y (Ignored): Present for API conventions. + X: Functions to be transformed. + y: Present for API conventions. Returns: - :class:`FDataGrid`: SRSF functions. + SRSF functions. Raises: ValueError: If functions are not univariate. - References: - .. [SK16-4-6-1] Srivastava, Anuj & Klassen, Eric P. (2016). - Functional and shape data analysis. In *Square-Root Slope - Function Representation* (pp. 91-93). Springer. - """ check_is_univariate(X) if self.output_points is None: output_points = X.grid_points[0] else: - output_points = self.output_points + output_points = np.asarray(self.output_points) g = X.derivative() @@ -175,11 +172,11 @@ def transform(self, X: FDataGrid, y=None): return X.copy(data_matrix=data_matrix, grid_points=output_points) - def inverse_transform(self, X: FDataGrid, y=None): - r"""Computes the inverse SRSF transform. + def inverse_transform(self, X: FDataGrid, y: None = None) -> FDataGrid: + r"""Compute the inverse SRSF transform. Given the srsf and the initial value the original function can be - obtained as [SK16-4-6-2]_ : + obtained as :footcite:`srivastava+klassen_2016_analysis_square`: .. math:: f(t) = f(a) + \int_{a}^t q(t)|q(t)|dt @@ -190,43 +187,45 @@ def inverse_transform(self, X: FDataGrid, y=None): estimator it is assumed that :math:`f(a)=0`. Args: - X (:class:`FDataGrid`): SRSF to be transformed. - y (Ignored): Present for API conventions. + X: SRSF to be transformed. + y: Present for API conventions. Returns: - :class:`FDataGrid`: Functions in the original space. + Functions in the original space. Raises: ValueError: If functions are multidimensional. - - References: - .. [SK16-4-6-2] Srivastava, Anuj & Klassen, Eric P. (2016). - Functional and shape data analysis. In *Square-Root Slope - Function Representation* (pp. 91-93). Springer. - """ check_is_univariate(X) - if self.initial_value is None and not hasattr(self, 'initial_value_'): - raise AttributeError("When initial_value=None is expected a " - "previous transformation of the data to " - "store the initial values to apply in the " - "inverse transformation. Also it is possible " - "to fix these values setting the attribute" - "initial value without a previous " - "transformation.") + stored_initial_value = getattr(self, 'initial_value_', None) + + if self.initial_value is None and stored_initial_value is None: + raise AttributeError( + "When initial_value=None is expected a " + "previous transformation of the data to " + "store the initial values to apply in the " + "inverse transformation. Also it is possible " + "to fix these values setting the attribute" + "initial value without a previous " + "transformation.", + ) if self.output_points is None: output_points = X.grid_points[0] else: - output_points = self.output_points + output_points = np.asarray(self.output_points) data_matrix = X(output_points) data_matrix *= np.abs(data_matrix) - f_data_matrix = scipy.integrate.cumtrapz(data_matrix, x=output_points, - axis=1, initial=0) + f_data_matrix = scipy.integrate.cumtrapz( + data_matrix, + x=output_points, + axis=1, + initial=0, + ) # If the transformer was fitted, sum the initial value if self.initial_value is None: @@ -237,31 +236,333 @@ def inverse_transform(self, X: FDataGrid, y=None): return X.copy(data_matrix=f_data_matrix, grid_points=output_points) -def _elastic_alignment_array(template_data, q_data, - eval_points, penalty, grid_dim): - r"""Wrapper between the cython interface and python. +def _elastic_alignment_array( + template_data: np.ndarray, + q_data: np.ndarray, + eval_points: np.ndarray, + penalty: float, + grid_dim: int, +) -> np.ndarray: + """ + Wrap the :func:`optimum_reparam` function of fdasrsf. Selects the corresponding routine depending on the dimensions of the arrays. Args: - template_data (numpy.ndarray): Array with the srsf of the template. - q_data (numpy.ndarray): Array with the srsf of the curves - to be aligned. - eval_points (numpy.ndarray): Discretisation points of the functions. - penalty (float): Penalisation term. - grid_dim (int): Dimension of the grid used in the alignment algorithm. - - Return: - (numpy.ndarray): Array with the same shape than q_data with the srsf of + template_data: Array with the srsf of the template. + q_data: Array with the srsf of the curves + to be aligned. + eval_points: Discretisation points of the functions. + penalty: Penalisation term. + grid_dim: Dimension of the grid used in the alignment algorithm. + + Returns: + Array with the same shape than q_data with the srsf of the functions aligned to the template(s). + """ + return optimum_reparam( + np.ascontiguousarray(template_data.T), + np.ascontiguousarray(eval_points), + np.ascontiguousarray(q_data.T), + method="DP2", + lam=penalty, grid_dim=grid_dim, + ).T + + +def warping_mean( + warping: FDataGrid, + *, + max_iter: int = 100, + tol: float = 1e-6, + step_size: float = 0.3, +) -> FDataGrid: + r"""Compute the karcher mean of a set of warpings. + + Let :math:`\gamma_i i=1...n` be a set of warping functions + :math:`\gamma_i:[a,b] \rightarrow [a,b]` in :math:`\Gamma`, i.e., + monotone increasing and with the restriction :math:`\gamma_i(a)=a \, + \gamma_i(b)=b`. + + The karcher mean :math:`\bar \gamma` is defined as the warping that + minimises locally the sum of Fisher-Rao squared distances + :footcite:`srivastava+klassen_2016_analysis_orbit`. + + .. math:: + \bar \gamma = argmin_{\gamma \in \Gamma} \sum_{i=1}^{n} + d_{FR}^2(\gamma, \gamma_i) - return optimum_reparam(np.ascontiguousarray(template_data.T), - np.ascontiguousarray(eval_points), - np.ascontiguousarray(q_data.T), - method="DP2", - lam=penalty, grid_dim=grid_dim).T + The computation is performed using the structure of Hilbert Sphere obtained + after a transformation of the warpings, see + :footcite:`srivastava++_2011_ficher-rao_orbit`. + + Args: + warping: Set of warpings. + max_iter: Maximum number of interations. Defaults to 100. + tol: Convergence criterion, if the norm of the mean of the + shooting vectors, :math:`| \bar v | 1e-10: + vmean += theta / np.sin(theta) * (psi_i - np.cos(theta) * mu) + + # Mean of shooting vectors + vmean /= warping.n_samples + v_norm = np.sqrt(scipy.integrate.simps(np.square(vmean))) + + # Convergence criterion + if v_norm < tol: + break + + # Calculate exponential map of mu + a = np.cos(step_size * v_norm) + b = np.sin(step_size * v_norm) / v_norm + mu = a * mu + b * vmean + + # Recover mean in original gamma space + warping_mean = scipy.integrate.cumtrapz( + np.square(mu, out=mu)[0], + x=eval_points, + initial=0, + ) + + # Affine traslation to original scale + warping_mean = _normalize_scale( + warping_mean, + a=original_eval_points[0], + b=original_eval_points[-1], + ) + + monotone_interpolation = SplineInterpolation( + interpolation_order=3, + monotone=True, + ) + + return FDataGrid( + [warping_mean], + grid_points=original_eval_points, + interpolation=monotone_interpolation, + ) + + +def elastic_mean( + fdatagrid: FDataGrid, + *, + penalty: float = 0, + center: bool = True, + max_iter: int = 20, + tol: float = 1e-3, + initial: Optional[float] = None, + grid_dim: int = 7, + **kwargs, +) -> FDataGrid: + r"""Compute the karcher mean under the elastic metric. + + Calculates the karcher mean of a set of functional samples in the amplitude + space :math:`\mathcal{A}=\mathcal{F}/\Gamma`. + + Let :math:`q_i` the corresponding SRSF of the observation :math:`f_i`. + The space :math:`\mathcal{A}` is defined using the equivalence classes + :math:`[q_i]=\{ q_i \circ \gamma \| \gamma \in \Gamma \}`, where + :math:`\Gamma` denotes the space of warping functions. The karcher mean + in this space is defined as + + .. math:: + [\mu_q] = argmin_{[q] \in \mathcal{A}} \sum_{i=1}^n + d_{\lambda}^2([q],[q_i]) + + Once :math:`[\mu_q]` is obtained it is selected the element of the + equivalence class which makes the mean of the warpings employed be the + identity. + + See :footcite:`srivastava+klassen_2016_analysis_karcher` and + :footcite:`srivastava++_2011_ficher-rao_karcher`. + + Args: + fdatagrid: Set of functions to compute the + mean. + penalty: Penalisation term. Defaults to 0. + center: If ``True`` it is computed the mean of the warpings and + used to select a central mean. Defaults ``True``. + max_iter: Maximum number of iterations. Defaults to 20. + tol: Convergence criterion, the algorithm will stop if + :math:`|mu_{(\nu)} - mu_{(\nu - 1)}|_2 / | mu_{(\nu-1)} |_2 < tol`. + initial: Value of the mean at the starting point. By default + takes the average of the initial points of the samples. + grid_dim: Dimension of the grid used in the alignment + algorithm. Defaults 7. + kwargs: Named options to be pased to :func:`warping_mean`. + + Returns: + FDatagrid with the mean of the functions. + + Raises: + ValueError: If the object is multidimensional or the shape of the srsf + do not match with the fdatagrid. + + References: + .. footbibliography:: + + """ + check_is_univariate(fdatagrid) + + srsf_transformer = SRSF(initial_value=0) + fdatagrid_srsf = srsf_transformer.fit_transform(fdatagrid) + eval_points = fdatagrid.grid_points[0] + + eval_points_normalized = _normalize_scale(eval_points) + y_scale = eval_points[-1] - eval_points[0] + + interpolation = SplineInterpolation(interpolation_order=3, monotone=True) + + # Discretisation points + fdatagrid_normalized = FDataGrid( + fdatagrid(eval_points) / y_scale, + grid_points=eval_points_normalized, + ) + + srsf = fdatagrid_srsf(eval_points)[..., 0] + + # Initialize with function closest to the L2 mean with the L2 distance + centered = (srsf.T - srsf.mean(axis=0, keepdims=True).T).T + + distances = scipy.integrate.simps( + np.square(centered, out=centered), + eval_points_normalized, axis=1, + ) + + # Initialization of iteration + mu = srsf[np.argmin(distances)] + mu_aux = np.empty(mu.shape) + mu_1 = np.empty(mu.shape) + + # Main iteration + for _ in range(max_iter): + + gammas_matrix = _elastic_alignment_array( + mu, + srsf, + eval_points_normalized, + penalty, + grid_dim, + ) + + gammas = FDataGrid( + gammas_matrix, + grid_points=eval_points_normalized, + interpolation=interpolation, + ) + + fdatagrid_normalized = fdatagrid_normalized.compose(gammas) + srsf = srsf_transformer.transform( + fdatagrid_normalized, + ).data_matrix[..., 0] + + # Next iteration + mu_1 = srsf.mean(axis=0, out=mu_1) + + # Convergence criterion + mu_norm = np.sqrt( + scipy.integrate.simps( + np.square(mu, out=mu_aux), + eval_points_normalized, + ), + ) + + mu_diff = np.sqrt( + scipy.integrate.simps( + np.square(mu - mu_1, out=mu_aux), + eval_points_normalized, + ), + ) + + if mu_diff / mu_norm < tol: + break + + mu = mu_1 + + if initial is None: + initial = fdatagrid.data_matrix[:, 0].mean() + + srsf_transformer.set_params(initial_value=initial) + + # Karcher mean orbit in space L2/Gamma + karcher_mean = srsf_transformer.inverse_transform( + fdatagrid.copy( + data_matrix=[mu], + grid_points=eval_points, + sample_names=("Karcher mean",), + ), + ) + + if center: + # Gamma mean in Hilbert Sphere + mean_normalized = warping_mean(gammas, **kwargs) + + gamma_mean = FDataGrid( + _normalize_scale( + mean_normalized.data_matrix[..., 0], + a=eval_points[0], + b=eval_points[-1], + ), + grid_points=eval_points, + ) + + gamma_inverse = invert_warping(gamma_mean) + + karcher_mean = karcher_mean.compose(gamma_inverse) + + # Return center of the orbit + return karcher_mean class ElasticRegistration(RegistrationTransformer): @@ -299,8 +600,8 @@ class ElasticRegistration(RegistrationTransformer): `elastic mean`, wich is the local minimum of the sum of squares of elastic distances. See :func:`~elastic_mean`. - In [SK16-4-2]_ are described extensively the algorithms employed and - the SRSF framework. + In :footcite:`srivastava+klassen_2016_analysis_elastic` are described + extensively the algorithms employed and the SRSF framework. Args: template (str, :class:`FDataGrid` or callable, optional): Template to @@ -316,18 +617,15 @@ class ElasticRegistration(RegistrationTransformer): alignment algorithm. Defaults 7. Attributes: - template_ (:class:`FDataGrid`): Template learned during fitting, + template\_: Template learned during fitting, used for alignment in :meth:`transform`. - warping_ (:class:`FDataGrid`): Warping applied during the last + warping\_: Warping applied during the last transformation. References: - .. [SK16-4-2] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Functional Data and Elastic - Registration* (pp. 73-122). Springer. + .. footbibliography:: Examples: - Elastic registration of with train/test sets. >>> from skfda.preprocessing.registration import \ @@ -350,38 +648,36 @@ class ElasticRegistration(RegistrationTransformer): """ - def __init__(self, template="elastic mean", penalty=0., output_points=None, - grid_dim=7): - """Initializes the registration transformer""" - + def __init__( + self, + template: Union[FDataGrid, _MeanType] = elastic_mean, + penalty: float = 0, + output_points: Optional[ArrayLike] = None, + grid_dim: int = 7, + ) -> None: self.template = template self.penalty = penalty self.output_points = output_points self.grid_dim = grid_dim - def fit(self, X: FDataGrid=None, y=None): + def fit(self, X: FDataGrid, y: None = None) -> RegistrationTransformer: """Fit the transformer. Learns the template used during the transformation. Args: - X (FDataGrid, optionl): Functional samples used as training - samples. If the template provided it is an FDataGrid this - samples are it is not need to construct the template from the - samples and this argument is ignored. - y (Ignored): Present for API conventions. + X: Functional observations used as training samples. If the + template provided is a FDataGrid this argument is ignored, as + it is not necessary to learn the template from the training + data. + y: Present for API conventions. Returns: - RegistrationTransformer: self. + self. """ if isinstance(self.template, FDataGrid): self.template_ = self.template # Template already constructed - elif X is None: - raise ValueError("Must be provided a dataset X to construct the " - "template.") - elif self.template == "elastic mean": - self.template_ = elastic_mean(X) else: self.template_ = self.template(X) @@ -391,26 +687,30 @@ def fit(self, X: FDataGrid=None, y=None): return self - def transform(self, X: FDataGrid, y=None): + def transform(self, X: FDataGrid, y: None = None) -> FDataGrid: """Apply elastic registration to the data. Args: - X (:class:`FDataGrid`): Functional data to be registered. - y (ignored): Present for API conventions. + X: Functional data to be registered. + y: Present for API conventions. Returns: - :class:`FDataGrid`: Registered samples. + Registered samples. """ check_is_fitted(self, '_template_srsf') check_is_univariate(X) - if (len(self._template_srsf) != 1 and - len(X) != len(self._template_srsf)): + if ( + len(self._template_srsf) != 1 + and len(X) != len(self._template_srsf) + ): - raise ValueError("The template should contain one sample to align " - "all the curves to the same function or the " - "same number of samples than X.") + raise ValueError( + "The template should contain one sample to align " + "all the curves to the same function or the " + "same number of samples than X.", + ) srsf = SRSF(output_points=self.output_points, initial_value=0) fdatagrid_srsf = srsf.fit_transform(X) @@ -432,24 +732,36 @@ def transform(self, X: FDataGrid, y=None): template_data = template_data[0] # Values of the warping - gamma = _elastic_alignment_array(template_data, q_data, - _normalize_scale(output_points), - self.penalty, self.grid_dim) + gamma = _elastic_alignment_array( + template_data, + q_data, + _normalize_scale(output_points), + self.penalty, + self.grid_dim, + ) # Normalize warping to original interval gamma = _normalize_scale( - gamma, a=output_points[0], b=output_points[-1]) + gamma, + a=output_points[0], + b=output_points[-1], + ) # Interpolation interpolation = SplineInterpolation( - interpolation_order=3, monotone=True) + interpolation_order=3, + monotone=True, + ) - self.warping_ = FDataGrid(gamma, output_points, - interpolation=interpolation) + self.warping_ = FDataGrid( + gamma, + output_points, + interpolation=interpolation, + ) return X.compose(self.warping_, eval_points=output_points) - def inverse_transform(self, X: FDataGrid, y=None): + def inverse_transform(self, X: FDataGrid, y: None = None) -> FDataGrid: r"""Reverse the registration procedure previosly applied. Let :math:`gamma(t)` the warping applied to construct a registered @@ -460,17 +772,18 @@ def inverse_transform(self, X: FDataGrid, y=None): :math:`f(t)=f^*(\gamma^{-1}(t))`. Args: - X (:class:`FDataGrid`): Functional data to apply the reverse + X: Functional data to apply the reverse transform. - y (Ignored): Present for API conventions. + y: Present for API conventions. Returns: - :class:`FDataGrid`: Functional data compose by the inverse warping. + Functional data compose by the inverse warping. Raises: ValueError: If the warpings :math:`\gamma` were not build via - :meth:`transform` or if the number of samples of `X` is different - than the number of samples of the dataset previosly transformed. + :meth:`transform` or if the number of samples of `X` is + different than the number of samples of the dataset + previously transformed. Examples: @@ -499,267 +812,20 @@ def inverse_transform(self, X: FDataGrid, y=None): :func:`invert_warping` """ - if not hasattr(self, 'warping_'): - raise ValueError("Data must be previosly transformed to apply the " - "inverse transform") - elif len(X) != len(self.warping_): - raise ValueError("Data must contain the same number of samples " - "than the dataset previously transformed") - - inverse_warping = invert_warping(self.warping_) - - return X.compose(inverse_warping, eval_points=self.output_points) - - -def warping_mean(warping, *, max_iter=100, tol=1e-6, step_size=.3): - r"""Compute the karcher mean of a set of warpings. - - Let :math:`\gamma_i i=1...n` be a set of warping functions - :math:`\gamma_i:[a,b] \rightarrow [a,b]` in :math:`\Gamma`, i.e., - monotone increasing and with the restriction :math:`\gamma_i(a)=a \, - \gamma_i(b)=b`. - - The karcher mean :math:`\bar \gamma` is defined as the warping that - minimises locally the sum of Fisher-Rao squared distances. - [SK16-8-3-2]_. - - .. math:: - \bar \gamma = argmin_{\gamma \in \Gamma} \sum_{i=1}^{n} - d_{FR}^2(\gamma, \gamma_i) - - The computation is performed using the structure of Hilbert Sphere obtained - after a transformation of the warpings, see [S11-3-3]_. - - Args: - warping (:class:`~skfda.FDataGrid`): Set of warpings. - max_iter (int): Maximum number of interations. Defaults to 100. - tol (float): Convergence criterion, if the norm of the mean of the - shooting vectors, :math:`| \bar v | 1e-10: - vmean += theta / np.sin(theta) * (psi_i - np.cos(theta) * mu) - - # Mean of shooting vectors - vmean /= warping.n_samples - v_norm = np.sqrt(scipy.integrate.simps(np.square(vmean))) - - # Convergence criterion - if v_norm < tol: - break - - # Calculate exponential map of mu - a = np.cos(step_size * v_norm) - b = np.sin(step_size * v_norm) / v_norm - mu = a * mu + b * vmean - - # Recover mean in original gamma space - warping_mean = scipy.integrate.cumtrapz(np.square(mu, out=mu)[0], - x=eval_points, initial=0) - - # Affine traslation to original scale - warping_mean = _normalize_scale(warping_mean, - a=original_eval_points[0], - b=original_eval_points[-1]) - - monotone_interpolation = SplineInterpolation(interpolation_order=3, - monotone=True) - - mean = FDataGrid([warping_mean], grid_points=original_eval_points, - interpolation=monotone_interpolation) - return mean + warping = getattr(self, 'warping_', None) + if warping is None: + raise ValueError( + "Data must be previosly transformed to apply the " + "inverse transform", + ) + elif len(X) != len(warping): + raise ValueError( + "Data must contain the same number of samples " + "than the dataset previously transformed", + ) -def elastic_mean(fdatagrid, *, penalty=0., center=True, max_iter=20, tol=1e-3, - initial=None, grid_dim=7, **kwargs): - r"""Compute the karcher mean under the elastic metric. - - Calculates the karcher mean of a set of functional samples in the amplitude - space :math:`\mathcal{A}=\mathcal{F}/\Gamma`. - - Let :math:`q_i` the corresponding SRSF of the observation :math:`f_i`. - The space :math:`\mathcal{A}` is defined using the equivalence classes - :math:`[q_i]=\{ q_i \circ \gamma \| \gamma \in \Gamma \}`, where - :math:`\Gamma` denotes the space of warping functions. The karcher mean - in this space is defined as - - .. math:: - [\mu_q] = argmin_{[q] \in \mathcal{A}} \sum_{i=1}^n - d_{\lambda}^2([q],[q_i]) - - Once :math:`[\mu_q]` is obtained it is selected the element of the - equivalence class which makes the mean of the warpings employed be the - identity. - - See [SK16-8-3-1]_ and [S11-3]_. - - Args: - fdatagrid (:class:`~skfda.FDataGrid`): Set of functions to compute the - mean. - penalty (float): Penalisation term. Defaults to 0. - center (boolean): If true it is computed the mean of the warpings and - used to select a central mean. Defaults True. - max_iter (int): Maximum number of iterations. Defaults to 20. - tol (float): Convergence criterion, the algorithm will stop if - :math:`|mu_{(\nu)} - mu_{(\nu - 1)}|_2 / | mu_{(\nu-1)} |_2 < tol`. - initial (float): Value of the mean at the starting point. By default - takes the average of the initial points of the samples. - grid_dim (int, optional): Dimension of the grid used in the alignment - algorithm. Defaults 7. - ** kwargs : Named options to be pased to :func:`warping_mean`. - - Return: - :class:`~skfda.FDataGrid`: FDatagrid with the mean of the functions. - - Raises: - ValueError: If the object is multidimensional or the shape of the srsf - do not match with the fdatagrid. - - References: - .. [SK16-8-3-1] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Karcher Mean of Amplitudes* - (pp. 273-274). Springer. - - .. [S11-3] Srivastava, Anuj et. al. Registration of Functional Data - Using Fisher-Rao Metric (2011). In *Karcher Mean and Function - Alignment* (pp. 7-10). arXiv:1103.3817v2. - - """ - check_is_univariate(fdatagrid) - - srsf_transformer = SRSF(initial_value=0) - fdatagrid_srsf = srsf_transformer.fit_transform(fdatagrid) - eval_points = fdatagrid.grid_points[0] - - eval_points_normalized = _normalize_scale(eval_points) - y_scale = eval_points[-1] - eval_points[0] - - interpolation = SplineInterpolation(interpolation_order=3, monotone=True) - - # Discretisation points - fdatagrid_normalized = FDataGrid(fdatagrid(eval_points) / y_scale, - grid_points=eval_points_normalized) - - srsf = fdatagrid_srsf(eval_points)[..., 0] - - # Initialize with function closest to the L2 mean with the L2 distance - centered = (srsf.T - srsf.mean(axis=0, keepdims=True).T).T - - distances = scipy.integrate.simps(np.square(centered, out=centered), - eval_points_normalized, axis=1) - - # Initialization of iteration - mu = srsf[np.argmin(distances)] - mu_aux = np.empty(mu.shape) - mu_1 = np.empty(mu.shape) - - # Main iteration - for _ in range(max_iter): - - gammas = _elastic_alignment_array( - mu, srsf, eval_points_normalized, penalty, grid_dim) - gammas = FDataGrid(gammas, grid_points=eval_points_normalized, - interpolation=interpolation) - - fdatagrid_normalized = fdatagrid_normalized.compose(gammas) - srsf = srsf_transformer.transform( - fdatagrid_normalized).data_matrix[..., 0] - - # Next iteration - mu_1 = srsf.mean(axis=0, out=mu_1) - - # Convergence criterion - mu_norm = np.sqrt(scipy.integrate.simps(np.square(mu, out=mu_aux), - eval_points_normalized)) - - mu_diff = np.sqrt(scipy.integrate.simps(np.square(mu - mu_1, - out=mu_aux), - eval_points_normalized)) - - if mu_diff / mu_norm < tol: - break - - mu = mu_1 - - if initial is None: - initial = fdatagrid.data_matrix[:, 0].mean() - - srsf_transformer.set_params(initial_value=initial) - - # Karcher mean orbit in space L2/Gamma - karcher_mean = srsf_transformer.inverse_transform( - fdatagrid.copy(data_matrix=[mu], grid_points=eval_points, - sample_names=("Karcher mean",))) + inverse_warping = invert_warping(warping) - if center: - # Gamma mean in Hilbert Sphere - mean_normalized = warping_mean(gammas, **kwargs) - - gamma_mean = FDataGrid(_normalize_scale( - mean_normalized.data_matrix[..., 0], - a=eval_points[0], - b=eval_points[-1]), - grid_points=eval_points) - - gamma_inverse = invert_warping(gamma_mean) - - karcher_mean = karcher_mean.compose(gamma_inverse) - - # Return center of the orbit - return karcher_mean + return X.compose(inverse_warping, eval_points=self.output_points) diff --git a/skfda/preprocessing/registration/validation.py b/skfda/preprocessing/registration/validation.py index fc27ee72f..92bc99bbf 100644 --- a/skfda/preprocessing/registration/validation.py +++ b/skfda/preprocessing/registration/validation.py @@ -1,14 +1,19 @@ -"""Methods and classes for validation of the registration procedures""" +"""Methods and classes for validation of the registration procedures.""" +from __future__ import annotations -from typing import NamedTuple +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional import numpy as np -from ..._utils import check_is_univariate, _to_grid +from ..._utils import _to_grid, check_is_univariate +from ...representation import FData +from .base import RegistrationTransformer -class RegistrationScorer(): - r"""Cross validation scoring for registration procedures. +class RegistrationScorer(ABC): + """Cross validation scoring for registration procedures. It calculates the score of a registration procedure, used to perform model validation or parameter selection. @@ -40,22 +45,23 @@ class RegistrationScorer(): """ - def __init__(self, eval_points=None): - """Initialize the transformer""" - self.eval_points = eval_points - - def __call__(self, estimator, X, y=None): + def __call__( + self, + estimator: RegistrationTransformer, + X: FData, + y: Optional[FData] = None, + ) -> float: """Compute the score of the transformation. Args: - estimator (Estimator): Registration method estimator. The estimator + estimator: Registration method estimator. The estimator should be fitted. - X (:class:`FData `): Functional data to be registered. - y (:class:`FData `, optional): Functional data target. - If provided should be the same as `X` in general. + X: Functional data to be registered. + y: Functional data target. If provided should be the same as + `X` in general. Returns: - float: Cross validation score. + Cross validation score. """ if y is None: y = X @@ -65,8 +71,27 @@ def __call__(self, estimator, X, y=None): return self.score_function(y, X_reg) + @abstractmethod + def score_function( + self, + X: FData, + y: FData, + ) -> float: + """Compute the score of the transformation performed. + + Args: + X: Original functional data. + y: Functional data registered. + + Returns: + Score of the transformation. -class AmplitudePhaseDecompositionStats(NamedTuple): + """ + pass + + +@dataclass +class AmplitudePhaseDecompositionStats(): r"""Named tuple to store the values of the amplitude-phase decomposition. Values of the amplitude phase decomposition computed in @@ -74,19 +99,22 @@ class AmplitudePhaseDecompositionStats(NamedTuple): Args: r_square (float): Squared correlation index :math:`R^2`. - mse_amp (float): Mean square error of amplitude + mse_amplitude (float): Mean square error of amplitude :math:`\text{MSE}_{amp}`. - mse_pha (float): Mean square error of phase :math:`\text{MSE}_{pha}`. + mse_phase (float): Mean square error of phase :math:`\text{MSE}_{pha}`. c_r (float): Constant :math:`C_R`. """ + r_squared: float - mse_amp: float - mse_pha: float + mse_amplitude: float + mse_phase: float c_r: float -class AmplitudePhaseDecomposition(RegistrationScorer): +class AmplitudePhaseDecomposition( + RegistrationScorer, +): r"""Compute mean square error measures for amplitude and phase variation. Once the registration has taken place, this function computes two mean @@ -111,15 +139,14 @@ class AmplitudePhaseDecomposition(RegistrationScorer): .. math:: \text{MSE}_{phase}= - \int \left [C_R \overline{y}^2(t) - \overline{x}^2(t) \right]dt + C_R \int \overline{y}^2(t) dt - \int \overline{x}^2(t) dt where the constant :math:`C_R` is defined as .. math:: - C_R = 1 + \frac{\frac{1}{N}\sum_{i}^{N}\int [Dh_i(t)-\overline{Dh}(t)] - [ y_i^2(t)- \overline{y^2}(t) ]dt} - {\frac{1}{N} \sum_{i}^{N} \int y_i^2(t)dt} + C_R = \frac{\frac{1}{N}\sum_{i=1}^{N}\int[x_i(t)-\overline x(t)]^2dt + }{\frac{1}{N}\sum_{i=1}^{N}\int[y_i(t)-\overline y(t)]^2dt} whose structure is related to the covariation between the deformation functions :math:`Dh_i(t)` and the squared registered functions @@ -183,7 +210,6 @@ class AmplitudePhaseDecomposition(RegistrationScorer): Springer. Examples: - Calculate the score of the shift registration of a sinusoidal process synthetically generated. @@ -204,20 +230,20 @@ class AmplitudePhaseDecomposition(RegistrationScorer): >>> scorer = AmplitudePhaseDecomposition() >>> score = scorer(shift_registration, X) >>> round(score, 3) - 0.972 + 0.971 - Also it is possible to get all the values of the decomposition. + Also it is possible to get all the values of the decomposition: - >>> scorer = AmplitudePhaseDecomposition(return_stats=True) - >>> stats = scorer(shift_registration, X) + >>> X_reg = shift_registration.transform(X) + >>> stats = scorer.stats(X, X_reg) >>> round(stats.r_squared, 3) - 0.972 - >>> round(stats.mse_amp, 3) - 0.007 - >>> round(stats.mse_pha, 3) - 0.227 + 0.971 + >>> round(stats.mse_amplitude, 3) + 0.006 + >>> round(stats.mse_phase, 3) + 0.214 >>> round(stats.c_r, 3) - 1.0 + 0.976 See also: @@ -227,128 +253,72 @@ class AmplitudePhaseDecomposition(RegistrationScorer): """ - def __init__(self, return_stats=False, eval_points=None): - """Initialize the transformer""" - super().__init__(eval_points) - self.return_stats = return_stats - - def __call__(self, estimator, X, y=None): - """Compute the score of the transformation. - - Args: - estimator (Estimator): Registration method estimator. The estimator - should be fitted. - X (:class:`FData `): Functional data to be registered. - y (:class:`FData `, optional): Functional data target. - If provided should be the same as `X` in general. - - Returns: - float: Cross validation score. + def stats( + self, + X: FData, + y: FData, + ) -> AmplitudePhaseDecompositionStats: """ - if y is None: - y = X - - # Register the data - X_reg = estimator.transform(X) - - # Pass the warpings if are generated in the transformer - if hasattr(estimator, 'warping_'): - return self.score_function(y, X_reg, warping=estimator.warping_) - else: - return self.score_function(y, X_reg) - - def score_function(self, X, y, *, warping=None): - """Compute the score of the transformation performed. + Compute the decomposition statistics. Args: - X (FData): Original functional data. - y (FData): Functional data registered. + X: Original functional data. + y: Functional data registered. Returns: - float: Score of the transformation. - + The decomposition statistics. """ - from scipy.integrate import simps + from ...misc.metrics import l2_distance, l2_norm check_is_univariate(X) check_is_univariate(y) if len(y) != len(X): - raise ValueError(f"the registered and unregistered curves must have " - f"the same number of samples ({len(y)})!=({len(X)})") - - if warping is not None and len(warping) != len(X): - raise ValueError(f"The registered curves and the warping functions " - f"must have the same number of samples " - f"({len(X)})!=({len(warping)})") - - # Creates the mesh to discretize the functions - if self.eval_points is None: - try: - eval_points = y.grid_points[0] - - except AttributeError: - nfine = max(y.basis.n_basis * 10 + 1, 201) - eval_points = np.linspace(*y.domain_range[0], nfine) - else: - eval_points = np.asarray(self.eval_points) - - x_fine = X.evaluate(eval_points)[..., 0] - y_fine = y.evaluate(eval_points)[..., 0] - mu_fine = x_fine.mean(axis=0) # Mean unregistered function - eta_fine = y_fine.mean(axis=0) # Mean registered function - mu_fine_sq = np.square(mu_fine) - eta_fine_sq = np.square(eta_fine) - - # Total mean square error of the original funtions - # mse_total = scipy.integrate.simps( - # np.mean(np.square(x_fine - mu_fine), axis=0), - # eval_points) - - cr = 1. # Constant related to the covariation between the deformation - # functions and y^2 - - # If the warping functions are not provided, are suppose independent - if warping is not None: - # Derivates warping functions - warping_deriv = warping.derivative() - dh_fine = warping_deriv(eval_points)[..., 0] - dh_fine_mean = dh_fine.mean(axis=0) - dh_fine_center = dh_fine - dh_fine_mean - - y_fine_sq = np.square(y_fine) # y^2 - y_fine_sq_center = np.subtract(y_fine_sq, eta_fine_sq) # y^2-E[y2] - - covariate = np.inner(dh_fine_center.T, y_fine_sq_center.T) - covariate = covariate.mean(axis=0) - cr += np.divide(simps(covariate, eval_points), - simps(eta_fine_sq, eval_points)) - - # mse due to phase variation - mse_pha = simps(cr * eta_fine_sq - mu_fine_sq, eval_points) - - # mse due to amplitude variation - # mse_amp = mse_total - mse_pha - y_fine_center = np.subtract(y_fine, eta_fine) - y_fine_center_sq = np.square(y_fine_center, out=y_fine_center) - y_fine_center_sq_mean = y_fine_center_sq.mean(axis=0) - - mse_amp = simps(y_fine_center_sq_mean, eval_points) - - # Total mean square error of the original funtions - mse_total = mse_pha + mse_amp + raise ValueError( + f"The registered and unregistered curves must have " + f"the same number of samples ({len(y)})!=({len(X)})", + ) + + X_mean = X.mean() + y_mean = y.mean() + + c_r = np.sum(l2_norm(X)**2) / np.sum(l2_norm(y)**2) + + mse_amplitude = c_r * np.mean(l2_distance(y, y.mean())**2) + mse_phase = (c_r * l2_norm(y_mean)**2 - l2_norm(X_mean)**2).item() + + # Should be equal to np.mean(l2_distance(X, X_mean)**2) + mse_total = mse_amplitude + mse_phase # squared correlation measure of proportion of phase variation - rsq = mse_pha / (mse_total) + rsq = mse_phase / mse_total + + return AmplitudePhaseDecompositionStats( + r_squared=rsq, + mse_amplitude=mse_amplitude, + mse_phase=mse_phase, + c_r=c_r, + ) + + def score_function( + self, + X: FData, + y: FData, + ) -> float: + """Compute the score of the transformation performed. - if self.return_stats is True: - stats = AmplitudePhaseDecompositionStats(rsq, mse_amp, mse_pha, cr) - return stats + Args: + X: Original functional data. + y: Functional data registered. - return rsq + Returns: + Score of the transformation. + + """ + return float(self.stats(X, y).r_squared) -class LeastSquares(AmplitudePhaseDecomposition): +class LeastSquares(RegistrationScorer): r"""Cross-validated measure of the registration procedure. Computes a cross-validated measure of the level of synchronization @@ -394,7 +364,6 @@ class LeastSquares(AmplitudePhaseDecomposition): (p. 18). arXiv:1103.3817v2. Examples: - Calculate the score of the shift registration of a sinusoidal process synthetically generated. @@ -414,7 +383,7 @@ class LeastSquares(AmplitudePhaseDecomposition): >>> scorer = LeastSquares() >>> score = scorer(shift_registration, X) >>> round(score, 3) - 0.796 + 0.953 See also: @@ -424,7 +393,7 @@ class LeastSquares(AmplitudePhaseDecomposition): """ - def score_function(self, X, y): + def score_function(self, X: FData, y: FData) -> float: """Compute the score of the transformation performed. Args: @@ -435,13 +404,11 @@ def score_function(self, X, y): float: Score of the transformation. """ - from ...misc.metrics import pairwise_distance, lp_distance + from ...misc.metrics import l2_distance check_is_univariate(X) check_is_univariate(y) - X, y = _to_grid(X, y, eval_points=self.eval_points) - # Instead of compute f_i - 1/(N-1) sum(j!=i)f_j for each i = 1 ... N # It is used (1 + 1/(N-1))f_i - 1/(N-1) sum(j=1 ... N) f_j = # (1 + 1/(N-1))f_i - N/(N-1) mean(f) = @@ -456,14 +423,13 @@ def score_function(self, X, y): mean_y = C2 * y.mean() # Compute distance to mean - distance = pairwise_distance(lp_distance) - ls_x = distance(X, mean_X).flatten() - ls_y = distance(y, mean_y).flatten() + ls_x = l2_distance(X, mean_X)**2 + ls_y = l2_distance(y, mean_y)**2 # Quotient of distance quotient = ls_y / ls_x - return 1 - 1. / N * quotient.sum() + return float(1 - np.mean(quotient)) class SobolevLeastSquares(RegistrationScorer): @@ -478,8 +444,8 @@ class SobolevLeastSquares(RegistrationScorer): {\sum_{i=1}^{N} \int\left(\dot{f}_{i}(t)-\frac{1}{N} \sum_{j=1}^{N} \dot{f}_{j}\right)^{2} dt} - where :math:`\dot f_i` and :math:`\dot \tilde f_i` are the derivatives of - the original and the registered data respectively. + where :math:`\dot{f}_i` and :math:`\dot{\tilde{f}}_i` are the derivatives + of the original and the registered data respectively. This criterion measures the total cross-sectional variance of the derivatives of the aligned functions, relative to the original value. @@ -510,7 +476,6 @@ class SobolevLeastSquares(RegistrationScorer): (p. 18). arXiv:1103.3817v2. Examples: - Calculate the score of the shift registration of a sinusoidal process synthetically generated. @@ -530,7 +495,7 @@ class SobolevLeastSquares(RegistrationScorer): >>> scorer = SobolevLeastSquares() >>> score = scorer(shift_registration, X) >>> round(score, 3) - 0.761 + 0.924 See also: :class:`~AmplitudePhaseDecomposition` @@ -539,7 +504,7 @@ class SobolevLeastSquares(RegistrationScorer): """ - def score_function(self, X, y): + def score_function(self, X: FData, y: FData) -> float: """Compute the score of the transformation performed. Args: @@ -550,7 +515,7 @@ def score_function(self, X, y): float: Score of the transformation. """ - from ...misc.metrics import pairwise_distance, lp_distance + from ...misc.metrics import l2_distance check_is_univariate(X) check_is_univariate(y) @@ -559,16 +524,11 @@ def score_function(self, X, y): X = X.derivative() y = y.derivative() - # Discretize if needed - X, y = _to_grid(X, y, eval_points=self.eval_points) - # L2 distance to mean - distance = pairwise_distance(lp_distance) - - sls_x = distance(X, X.mean()) - sls_y = distance(y, y.mean()) + sls_x = l2_distance(X, X.mean())**2 + sls_y = l2_distance(y, y.mean())**2 - return 1 - sls_y.sum() / sls_x.sum() + return float(1 - sls_y.sum() / sls_x.sum()) class PairwiseCorrelation(RegistrationScorer): @@ -609,7 +569,6 @@ class PairwiseCorrelation(RegistrationScorer): (p. 18). arXiv:1103.3817v2. Examples: - Calculate the score of the shift registration of a sinusoidal process synthetically generated. @@ -638,7 +597,10 @@ class PairwiseCorrelation(RegistrationScorer): """ - def score_function(self, X, y): + def __init__(self, eval_points: Optional[np.ndarray] = None) -> None: + self.eval_points = eval_points + + def score_function(self, X: FData, y: FData) -> float: """Compute the score of the transformation performed. Args: @@ -659,9 +621,9 @@ def score_function(self, X, y): # corrcoefs computes the correlation between vector, without weights # due to the sample points X_corr = np.corrcoef(X.data_matrix[..., 0]) - np.fill_diagonal(X_corr, 0.) + np.fill_diagonal(X_corr, 0) y_corr = np.corrcoef(y.data_matrix[..., 0]) - np.fill_diagonal(y_corr, 0.) + np.fill_diagonal(y_corr, 0) - return y_corr.sum() / X_corr.sum() + return float(y_corr.sum() / X_corr.sum()) diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index a27662c8f..77d51191f 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -4,131 +4,27 @@ This module contains the class for the basis smoothing. """ -from enum import Enum -from typing import Union, Iterable +from __future__ import annotations -import scipy.linalg +from typing import Optional import numpy as np +from typing_extensions import Final -from ... import FDataBasis -from ... import FDataGrid -from ..._utils import _cartesian_product -from ._linear import _LinearSmoother - - -class _Cholesky(): - """Solve the linear equation using cholesky factorization""" - - def __call__(self, *, basis_values, weight_matrix, data_matrix, - penalty_matrix, **_): - - common_matrix = basis_values.T - - if weight_matrix is not None: - common_matrix @= weight_matrix - - right_matrix = common_matrix @ data_matrix - left_matrix = common_matrix @ basis_values - - # Adds the roughness penalty to the equation - if penalty_matrix is not None: - left_matrix += penalty_matrix - - coefficients = scipy.linalg.cho_solve(scipy.linalg.cho_factor( - left_matrix, lower=True), right_matrix) - - # The ith column is the coefficients of the ith basis for each - # sample - coefficients = coefficients.T - - return coefficients - - -class _QR(): - """Solve the linear equation using qr factorization""" - - def __call__(self, *, basis_values, weight_matrix, data_matrix, - penalty_matrix, **_): - - if weight_matrix is not None: - # Decompose W in U'U and calculate UW and Uy - upper = scipy.linalg.cholesky(weight_matrix) - basis_values = upper @ basis_values - data_matrix = upper @ data_matrix - - if not np.all(penalty_matrix == 0): - w, v = np.linalg.eigh(penalty_matrix) - - w = w[::-1] - v = v[:, ::-1] - - w = np.maximum(w, 0) - - penalty_matrix = v @ np.diag(np.sqrt(w)) - # Augment the basis matrix with the square root of the - # penalty matrix - basis_values = np.concatenate([ - basis_values, - penalty_matrix.T], - axis=0) - # Augment data matrix by n zeros - data_matrix = np.pad(data_matrix, - ((0, len(v)), - (0, 0)), - mode='constant') - - # Resolves the equation - # B.T @ B @ C = B.T @ D - # by means of the QR decomposition - - # B = Q @ R - q, r = np.linalg.qr(basis_values) - right_matrix = q.T @ data_matrix - - # R @ C = Q.T @ D - coefficients = np.linalg.solve(r, right_matrix) - # The ith column is the coefficients of the ith basis for each - # sample - coefficients = coefficients.T - - return coefficients - - -class _Matrix(): - """Solve the linear equation using matrix inversion""" - - def fit(self, estimator, X, y=None): - if estimator.return_basis: - estimator._cached_coef_matrix = estimator._coef_matrix( - estimator.input_points_) - else: - # Force caching the hat matrix - estimator.hat_matrix() - - def fit_transform(self, estimator, X, y=None): - return estimator.fit(X, y).transform(X, y) - - def __call__(self, *, estimator, **_): - pass - - def transform(self, estimator, X, y=None): - if estimator.return_basis: - coefficients = (X.data_matrix.reshape((X.n_samples, -1)) - @ estimator._cached_coef_matrix.T) - - fdatabasis = FDataBasis( - basis=estimator.basis, coefficients=coefficients) +import scipy.linalg - return fdatabasis - else: - # The matrix is cached - return X.copy(data_matrix=self.hat_matrix() @ X.data_matrix, - grid_points=estimator.output_points_) +from ..._utils import _cartesian_product, _to_grid_points +from ...misc.lstsq import LstsqMethod, solve_regularized_weighted_lstsq +from ...misc.regularization import TikhonovRegularization +from ...representation import FData, FDataBasis, FDataGrid +from ...representation._typing import GridPointsLike +from ...representation.basis import Basis +from ._linear import _LinearSmoother class BasisSmoother(_LinearSmoother): - r"""Transform raw data to a smooth functional form. + r""" + Transform raw data to a smooth functional form. Takes functional data in a discrete form and makes an approximates it to the closest function that can be generated by the basis.a. @@ -167,30 +63,28 @@ class BasisSmoother(_LinearSmoother): [RS05-5-2-8]_ Args: - basis: (Basis): Basis used. - weights (array_like, optional): Matrix to weight the - observations. Defaults to the identity matrix. - smoothing_parameter (int or float, optional): Smoothing - parameter. Trying with several factors in a logarithm scale is - suggested. If 0 no smoothing is performed. Defaults to 1. - regularization (int, iterable or :class:`Regularization`): - Regularization object. This allows the penalization of + basis: Basis used. + weights: Matrix to weight the observations. Defaults to the identity + matrix. + smoothing_parameter: Smoothing parameter. Trying with several + factors in a logarithm scale is suggested. If 0 no smoothing is + performed. Defaults to 1. + regularization: Regularization object. This allows the penalization of complicated models, which applies additional smoothing. By default is ``None`` meaning that no additional smoothing has to take place. - method (str): Algorithm used for calculating the coefficients using + method: Algorithm used for calculating the coefficients using the least squares method. The values admitted are 'cholesky', 'qr' - and 'matrix' for Cholesky and QR factorisation methods, and matrix - inversion respectively. The default is 'cholesky'. - output_points (ndarray, optional): The output points. If ommited, - the input points are used. If ``return_basis`` is ``True``, this - parameter is ignored. - return_basis (boolean): If ``False`` (the default) returns the smoothed + and 'svd' for Cholesky, QR and SVD factorisation methods + respectively, or a callable similar to the `lstsq` function. The + default is 'svd', which is the most robust but less performant one. + output_points: The output points. If ommited, the input points are + used. If ``return_basis`` is ``True``, this parameter is ignored. + return_basis: If ``False`` (the default) returns the smoothed data as an FDataGrid, like the other smoothers. If ``True`` returns a FDataBasis object. Examples: - By default, this smoother returns a FDataGrid, like the other smoothers: @@ -203,8 +97,7 @@ class BasisSmoother(_LinearSmoother): >>> fd = skfda.FDataGrid(data_matrix=x, grid_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) - >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='cholesky') + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother(basis) >>> fd_smooth = smoother.fit_transform(fd) >>> fd_smooth.data_matrix.round(2) array([[[ 3.], @@ -219,19 +112,28 @@ class BasisSmoother(_LinearSmoother): >>> fd = skfda.FDataGrid(data_matrix=x, grid_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='cholesky', return_basis=True) + ... basis, + ... method='cholesky', + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2. , 0.71, 0.71]]) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='qr', return_basis=True) + ... basis, + ... method='qr', + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2. , 0.71, 0.71]]) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='matrix', return_basis=True) + ... basis, + ... method='svd', + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2. , 0.71, 0.71]]) @@ -251,10 +153,13 @@ class BasisSmoother(_LinearSmoother): >>> fd = skfda.FDataGrid(data_matrix=x, grid_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='cholesky', - ... regularization=TikhonovRegularization( - ... LinearDifferentialOperator([0.1, 0.2])), - ... return_basis=True) + ... basis, + ... method='cholesky', + ... regularization=TikhonovRegularization( + ... LinearDifferentialOperator([0.1, 0.2]), + ... ), + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2.04, 0.51, 0.55]]) @@ -262,10 +167,13 @@ class BasisSmoother(_LinearSmoother): >>> fd = skfda.FDataGrid(data_matrix=x, grid_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='qr', - ... regularization=TikhonovRegularization( - ... LinearDifferentialOperator([0.1, 0.2])), - ... return_basis=True) + ... basis, + ... method='qr', + ... regularization=TikhonovRegularization( + ... LinearDifferentialOperator([0.1, 0.2]), + ... ), + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2.04, 0.51, 0.55]]) @@ -273,10 +181,13 @@ class BasisSmoother(_LinearSmoother): >>> fd = skfda.FDataGrid(data_matrix=x, grid_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( - ... basis, method='matrix', - ... regularization=TikhonovRegularization( - ... LinearDifferentialOperator([0.1, 0.2])), - ... return_basis=True) + ... basis, + ... method='svd', + ... regularization=TikhonovRegularization( + ... LinearDifferentialOperator([0.1, 0.2]), + ... ), + ... return_basis=True, + ... ) >>> fd_basis = smoother.fit_transform(fd) >>> fd_basis.coefficients.round(2) array([[ 2.04, 0.51, 0.55]]) @@ -294,210 +205,133 @@ class BasisSmoother(_LinearSmoother): _required_parameters = ["basis"] - class SolverMethod(Enum): - cholesky = _Cholesky() - qr = _QR() - matrix = _Matrix() - - def __init__(self, - basis, - *, - smoothing_parameter: float = 1., - weights=None, - regularization: Union[int, Iterable[float], - 'LinearDifferentialOperator'] = None, - output_points=None, - method='cholesky', - return_basis=False): + def __init__( + self, + basis: Basis, + *, + smoothing_parameter: float = 1.0, + weights: Optional[np.ndarray] = None, + regularization: Optional[TikhonovRegularization[FDataGrid]] = None, + output_points: Optional[GridPointsLike] = None, + method: LstsqMethod = 'svd', + return_basis: bool = False, + ) -> None: self.basis = basis self.smoothing_parameter = smoothing_parameter self.weights = weights self.regularization = regularization self.output_points = output_points self.method = method - self.return_basis = return_basis - - def _method_function(self): - """ Return the method function""" - method_function = self.method - if not isinstance(method_function, self.SolverMethod): - method_function = self.SolverMethod[ - method_function.lower()] - - return method_function.value - - def _coef_matrix(self, input_points): - """Get the matrix that gives the coefficients""" + self.return_basis: Final = return_basis + + def _coef_matrix( + self, + input_points: GridPointsLike, + *, + data_matrix: Optional[np.ndarray] = None, + ) -> np.ndarray: + """Get the matrix that gives the coefficients.""" from ...misc.regularization import compute_penalty_matrix basis_values_input = self.basis.evaluate( - _cartesian_product(input_points)).reshape( - (self.basis.n_basis, -1)).T - - # If no weight matrix is given all the weights are one - if self.weights is not None: - ols_matrix = (basis_values_input.T @ self.weights - @ basis_values_input) - else: - ols_matrix = basis_values_input.T @ basis_values_input + _cartesian_product(_to_grid_points(input_points)), + ).reshape((self.basis.n_basis, -1)).T penalty_matrix = compute_penalty_matrix( basis_iterable=(self.basis,), regularization_parameter=self.smoothing_parameter, - regularization=self.regularization) - - ols_matrix += penalty_matrix - - right_side = basis_values_input.T - if self.weights is not None: - right_side @= self.weights - - return np.linalg.solve( - ols_matrix, right_side) - - def _hat_matrix(self, input_points, output_points): - basis_values_output = self.basis.evaluate(_cartesian_product( - output_points)).reshape( - (self.basis.n_basis, -1)).T + regularization=self.regularization, + ) + + # Get the matrix for computing the coefficients if no + # data_matrix is passed + if data_matrix is None: + data_matrix = np.eye(basis_values_input.shape[0]) + + return solve_regularized_weighted_lstsq( + coefs=basis_values_input, + result=data_matrix, + weights=self.weights, + penalty_matrix=penalty_matrix, + lstsq_method=self.method, + ) + + def _hat_matrix( + self, + input_points: GridPointsLike, + output_points: GridPointsLike, + ) -> np.ndarray: + basis_values_output = self.basis.evaluate( + _cartesian_product( + _to_grid_points(output_points), + ), + ).reshape((self.basis.n_basis, -1)).T return basis_values_output @ self._coef_matrix(input_points) - def fit(self, X: FDataGrid, y=None): + def fit( + self, + X: FDataGrid, + y: None = None, + ) -> BasisSmoother: """Compute the hat matrix for the desired output points. Args: - X (FDataGrid): - The data whose points are used to compute the matrix. - y : Ignored - Returns: - self (object) - - """ - - self.input_points_ = X.grid_points - self.output_points_ = (self.output_points - if self.output_points is not None - else self.input_points_) - - method = self._method_function() - method_fit = getattr(method, "fit", None) - if method_fit is not None: - method_fit(estimator=self, X=X, y=y) - - return self + X: The data whose points are used to compute the matrix. + y: Ignored. - def fit_transform(self, X: FDataGrid, y=None): - """Compute the hat matrix for the desired output points. - - Args: - X (FDataGrid): - The data whose points are used to compute the matrix. - y : Ignored Returns: - self (object) + self """ - from ...misc.regularization import compute_penalty_matrix - self.input_points_ = X.grid_points - self.output_points_ = (self.output_points - if self.output_points is not None - else self.input_points_) - - penalty_matrix = compute_penalty_matrix( - basis_iterable=(self.basis,), - regularization_parameter=self.smoothing_parameter, - regularization=self.regularization) - - # n is the samples - # m is the observations - # k is the number of elements of the basis - - # Each sample in a column (m x n) - data_matrix = X.data_matrix.reshape((X.n_samples, -1)).T - - # Each basis in a column - basis_values = self.basis.evaluate( - _cartesian_product(self.input_points_)).reshape( - (self.basis.n_basis, -1)).T - - # If no weight matrix is given all the weights are one - weight_matrix = self.weights - - # We need to solve the equation - # (phi' W phi + lambda * R) C = phi' W Y - # where: - # phi is the basis_values - # W is the weight matrix - # lambda the smoothness parameter - # C the coefficient matrix (the unknown) - # Y is the data_matrix - - if(data_matrix.shape[0] > self.basis.n_basis - or self.smoothing_parameter > 0): - - method = self._method_function() - - # If the method provides the complete transformation use it - method_fit_transform = getattr(method, "fit_transform", None) - if method_fit_transform is not None: - return method_fit_transform(estimator=self, X=X, y=y) - - # Otherwise the method is used to compute the coefficients - coefficients = method(estimator=self, - basis_values=basis_values, - weight_matrix=weight_matrix, - data_matrix=data_matrix, - penalty_matrix=penalty_matrix) - - elif data_matrix.shape[0] == self.basis.n_basis: - # If the number of basis equals the number of points and no - # smoothing is required - coefficients = np.linalg.solve(basis_values, data_matrix).T - - else: # data_matrix.shape[0] < basis.n_basis - raise ValueError(f"The number of basis functions " - f"({self.basis.n_basis}) " - f"exceed the number of points to be smoothed " - f"({data_matrix.shape[0]}).") - - fdatabasis = FDataBasis( - basis=self.basis, coefficients=coefficients, - dataset_name=X.dataset_name, - argument_names=X.argument_names, - coordinate_names=X.coordinate_names, - sample_names=X.sample_names) + self.output_points_ = ( + _to_grid_points(self.output_points) + if self.output_points is not None + else self.input_points_ + ) - if self.return_basis: - return fdatabasis - else: - return fdatabasis.to_grid(grid_points=self.output_points_) + if not self.return_basis: + super().fit(X, y) return self - def transform(self, X: FDataGrid, y=None): - """Apply the smoothing. + def transform( + self, + X: FDataGrid, + y: None = None, + ) -> FData: + """ + Smooth the data. Args: - X (FDataGrid): - The data to smooth. - y : Ignored + X: The data to smooth. + y: Ignored + Returns: - self (object) + Smoothed data. """ + assert all( + np.array_equal(i, s) for i, s in zip( + self.input_points_, + X.grid_points, + ) + ) - assert all([all(i == s) - for i, s in zip(self.input_points_, X.grid_points)]) - - method = self._method_function() - - # If the method provides the complete transformation use it - method_transform = getattr(method, "transform", None) - if method_transform is not None: - return method_transform(estimator=self, X=X, y=y) - - # Otherwise use fit_transform over the data - # Note that data leakage is not possible because the matrix only - # depends on the input/output points - return self.fit_transform(X, y) + if self.return_basis: + coefficients = self._coef_matrix( + input_points=X.grid_points, + data_matrix=X.data_matrix.reshape((X.n_samples, -1)).T, + ).T + + return FDataBasis( + basis=self.basis, + coefficients=coefficients, + dataset_name=X.dataset_name, + argument_names=X.argument_names, + coordinate_names=X.coordinate_names, + sample_names=X.sample_names, + ) + + return super().transform(X, y) diff --git a/skfda/preprocessing/smoothing/_linear.py b/skfda/preprocessing/smoothing/_linear.py index 0729290dd..f9d787d73 100644 --- a/skfda/preprocessing/smoothing/_linear.py +++ b/skfda/preprocessing/smoothing/_linear.py @@ -4,21 +4,24 @@ This module contains the abstract base class for all linear smoothers. """ -import abc +from __future__ import annotations -from sklearn.base import BaseEstimator, TransformerMixin +import abc +from typing import Any, Mapping, Optional import numpy as np +from sklearn.base import BaseEstimator, TransformerMixin from ... import FDataGrid +from ..._utils import _to_grid_points +from ...representation._typing import GridPointsLike -def _check_r_to_r(f): - if f.dim_domain != 1 or f.dim_codomain != 1: - raise NotImplementedError("Only accepts functions from R to R") - - -class _LinearSmoother(abc.ABC, BaseEstimator, TransformerMixin): +class _LinearSmoother( + abc.ABC, + BaseEstimator, # type: ignore + TransformerMixin, # type: ignore +): """Linear smoother. Abstract base class for all linear smoothers. The subclasses must override @@ -26,98 +29,110 @@ class _LinearSmoother(abc.ABC, BaseEstimator, TransformerMixin): """ - def __init__(self, *, - output_points=None): + def __init__( + self, + *, + output_points: Optional[GridPointsLike] = None, + ): self.output_points = output_points - def hat_matrix(self, input_points=None, output_points=None): - cached_input_points = getattr(self, "input_points_", None) - cached_output_points = getattr(self, "output_points_", None) + def hat_matrix( + self, + input_points: Optional[GridPointsLike] = None, + output_points: Optional[GridPointsLike] = None, + ) -> np.ndarray: # Use the fitted points if they are not provided if input_points is None: - input_points = cached_input_points + input_points = self.input_points_ if output_points is None: - output_points = cached_output_points - - if (cached_input_points is not None and - np.array_equal(input_points, cached_input_points) and - np.array_equal(output_points, cached_output_points)): - cached_hat_matrix = getattr(self, "_cached_hat_matrix", None) - if cached_hat_matrix is None: - self.cached_hat_matrix = self._hat_matrix( - input_points=self.input_points_, - output_points=self.output_points_ - ) - return self.cached_hat_matrix - - else: - # We only cache the matrix for the fit points - return self._hat_matrix( - input_points=self.input_points_, - output_points=self.output_points_ - ) + output_points = self.output_points_ + + return self._hat_matrix( + input_points=self.input_points_, + output_points=self.output_points_, + ) @abc.abstractmethod - def _hat_matrix(self, input_points, output_points): + def _hat_matrix( + self, + input_points: GridPointsLike, + output_points: GridPointsLike, + ) -> np.ndarray: pass - def _more_tags(self): + def _more_tags(self) -> Mapping[str, Any]: return { - 'X_types': [] + 'X_types': [], } - def fit(self, X: FDataGrid, y=None): + def fit( + self, + X: FDataGrid, + y: None = None, + ) -> _LinearSmoother: """Compute the hat matrix for the desired output points. Args: - X (FDataGrid): - The data whose points are used to compute the matrix. - y : Ignored + X: The data whose points are used to compute the matrix. + y: Ignored. + Returns: - self (object) + self """ - _check_r_to_r(X) - - self.input_points_ = X.grid_points[0] - self.output_points_ = (self.output_points - if self.output_points is not None - else self.input_points_) + self.input_points_ = X.grid_points + self.output_points_ = ( + _to_grid_points(self.output_points) + if self.output_points is not None + else self.input_points_ + ) - # Force caching the hat matrix - self.hat_matrix() + self.hat_matrix_ = self.hat_matrix() return self - def transform(self, X: FDataGrid, y=None): - """Multiplies the hat matrix for the functions values to smooth them. + def transform( + self, + X: FDataGrid, + y: None = None, + ) -> FDataGrid: + """Multiply the hat matrix with the function values to smooth them. Args: - X (FDataGrid): - The data to smooth. - y : Ignored + X: The data to smooth. + y: Ignored + Returns: - FDataGrid: Functional data smoothed. + Functional data smoothed. """ - - assert all(self.input_points_ == X.grid_points[0]) + assert all( + np.array_equal(i, s) for i, s in zip( + self.input_points_, + X.grid_points, + ) + ) # The matrix is cached - return X.copy(data_matrix=self.hat_matrix() @ X.data_matrix, - grid_points=self.output_points_) - - def score(self, X, y): - """Returns the generalized cross validation (GCV) score. + return X.copy( + data_matrix=self.hat_matrix_ @ X.data_matrix, + grid_points=self.output_points_, + ) + + def score( + self, + X: FDataGrid, + y: FDataGrid, + ) -> float: + """Return the generalized cross validation (GCV) score. Args: - X (FDataGrid): - The data to smooth. - y (FDataGrid): - The target data. Typically the same as ``X``. + X: The data to smooth. + y: The target data. Typically the same as ``X``. + Returns: - float: Generalized cross validation score. + Generalized cross validation score. """ from .validation import LinearSmootherGeneralizedCVScorer diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 263496fa7..fe3b0a584 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -13,7 +13,6 @@ from ...misc import kernels from ._linear import _LinearSmoother - __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" @@ -31,8 +30,8 @@ def __init__(self, *, smoothing_parameter=None, def _hat_matrix(self, input_points, output_points): return self._hat_matrix_function( - input_points=input_points, - output_points=output_points, + input_points=input_points[0], + output_points=output_points[0], smoothing_parameter=self.smoothing_parameter, kernel=self.kernel, weights=self.weights, @@ -82,14 +81,14 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): It is a linear kernel smoothing method. Uses an smoothing matrix :math:`\hat{H}` for the discretisation points in argvals by the Nadaraya-Watson estimator. The smoothed - values :math:`\hat{Y}` can be calculated as :math:`\hat{ - Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the - points of discretisation :math:`(x_1, x_2, ..., x_n)`. + values :math:`\hat{X}` at the points :math:`(t_1', t_2', ..., t_m')` + can be calculated as :math:`\hat{X} = \hat{H}X` where :math:`X` is the + vector of observations at the points of discretisation + :math:`(t_1, t_2, ..., t_n)` and .. math:: - \hat{H}_{i,j} = \frac{K\left(\frac{x_i-x_j}{h}\right)}{\sum_{k=1}^{ - n}K\left( - \frac{x_i-x_k}{h}\right)} + \hat{H}_{i,j} = \frac{K\left(\frac{t_j-t_i'}{h}\right)}{\sum_{k=1}^{ + n}K\left(\frac{t_k-t_i'}{h}\right)} where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel window width or smoothing parameter. @@ -161,6 +160,9 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.017, 0.053, 0.238, 0.346, 0.346], [ 0.006, 0.022, 0.163, 0.305, 0.503]]) + References: + Wasserman, L. (2006). Local Regression. + In *All of Nonparametric Statistics* (pp. 71). Springer. """ def _hat_matrix_function_not_normalized(self, *, delta_x, @@ -179,19 +181,20 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): It is a linear kernel smoothing method. Uses an smoothing matrix :math:`\hat{H}` for the discretisation points in argvals by the local linear regression estimator. The smoothed - values :math:`\hat{Y}` can be calculated as :math:`\hat{ - Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the points - of discretisation :math:`(x_1, x_2, ..., x_n)`. + values :math:`\hat{X}` at the points :math:`(t_1', t_2', ..., t_m')` + can be calculated as :math:`\hat{X} = \hat{H}X` where :math:`X` is the + vector of observations at the points of discretisation + :math:`(t_1, t_2, ..., t_n)` and .. math:: - \hat{H}_{i,j} = \frac{b_i(x_j)}{\sum_{k=1}^{n}b_k(x_j)} + \hat{H}_{i,j} = \frac{b_j(t_i')}{\sum_{k=1}^{n}b_k(t_i')} .. math:: - b_i(x) = K\left(\frac{x_i - x}{h}\right) S_{n,2}(x) - (x_i - x)S_{n, - 1}(x) + b_j(t') = K\left(\frac{t_j - t'}{h}\right) S_{n,2}(t') - + (t_j - t')S_{n,1}(t') .. math:: - S_{n,k} = \sum_{i=1}^{n}K\left(\frac{x_i-x}{h}\right)(x_i-x)^k + S_{n,k}(t') = \sum_{j=1}^{n}K\left(\frac{t_j-t'}{h}\right)(t_j-t')^k where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel window width. @@ -263,6 +266,9 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [-0.098, -0.202, -0.003, 0.651, 0.651], [-0.012, -0.032, -0.025, 0.154, 0.915]]) + References: + Wasserman, L. (2006). Local Regression. + In *All of Nonparametric Statistics* (pp. 77). Springer. """ def _hat_matrix_function_not_normalized(self, *, delta_x, @@ -276,11 +282,24 @@ def _hat_matrix_function_not_normalized(self, *, delta_x, class KNeighborsSmoother(_LinearKernelSmoother): - """K-nearest neighbour kernel smoother. + r"""K-nearest neighbour kernel smoother. It is a linear kernel smoothing method. Uses an smoothing matrix S for the discretisation points in argvals by - the k nearest neighbours estimator. + the :math:`k` nearest neighbours estimator. + + The smoothed values :math:`\hat{X}` at the points + :math:`(t_1', t_2', ..., t_m')` can be calculated as + :math:`\hat{X} = \hat{H}X` where :math:`X` is the vector of observations + at the points of discretisation :math:`(t_1, t_2, ..., t_n)` and + + .. math:: + + H_{i,j} =\frac{K\left(\frac{t_j-t_i'}{h_{ik}}\right)}{\sum_{r=1}^n + K\left(\frac{t_r-t_i'}{h_{ik}}\right)} + + :math:`K(\cdot)` is a kernel function and :math:`h_{ik}` the is the + distance from :math:`t_i'` to the 𝑘-th nearest neighbor of :math:`t_i'`. Usually used with the uniform kernel, it takes the average of the closest k points to a given point. @@ -359,6 +378,10 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 0. , 0. , 0. , 0.5 , 0.5 ], [ 0. , 0. , 0. , 0.5 , 0.5 ]]) + References: + Frederic Ferraty, Philippe Vieu (2006). kNN Estimator. + In *Nonparametric Functional Data Analysis: Theory and Practice* + (pp. 116). Springer. """ def __init__(self, *, smoothing_parameter=None, @@ -393,9 +416,5 @@ def _hat_matrix_function_not_normalized(self, *, delta_x, axis=1, interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) - # Applies the kernel to the result of dividing each row by the result - # of the previous operation, all the discretisation points - # corresponding to the knn are below 1 and the rest above 1 so the - # kernel returns values distinct to 0 only for the knn. return rr diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index a01080707..411f547d7 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -1,10 +1,8 @@ """Defines methods for the validation of the smoothing.""" +import numpy as np import sklearn from sklearn.model_selection import GridSearchCV -import numpy as np - - __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" @@ -17,7 +15,7 @@ def _get_input_estimation_and_matrix(estimator, X): estimator.fit(X) y_est = estimator.transform(X) - hat_matrix = estimator.hat_matrix() + hat_matrix = estimator.hat_matrix_ return y_est, hat_matrix diff --git a/skfda/py.typed b/skfda/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/skfda/representation/_evaluation_trasformer.py b/skfda/representation/_evaluation_trasformer.py index b91444c31..f470057a1 100644 --- a/skfda/representation/_evaluation_trasformer.py +++ b/skfda/representation/_evaluation_trasformer.py @@ -1,10 +1,22 @@ +from __future__ import annotations + +from typing import Optional, Union, overload + +import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from typing_extensions import Literal + from ._functional_data import FData +from ._typing import ArrayLike, GridPointsLike +from .extrapolation import ExtrapolationLike from .grid import FDataGrid -class EvaluationTransformer(BaseEstimator, TransformerMixin): +class EvaluationTransformer( + BaseEstimator, # type:ignore + TransformerMixin, # type:ignore +): r""" Transformer returning the evaluations of FData objects as a matrix. @@ -25,10 +37,9 @@ class EvaluationTransformer(BaseEstimator, TransformerMixin): the parameter has no efect. Defaults to False. Attributes: - shape_ (tuple): original shape of coefficients per sample. + shape\_ (tuple): original shape of coefficients per sample. Examples: - >>> from skfda.representation import (FDataGrid, FDataBasis, ... EvaluationTransformer) >>> from skfda.representation.basis import Monomial @@ -82,32 +93,68 @@ class EvaluationTransformer(BaseEstimator, TransformerMixin): """ - def __init__(self, eval_points=None, *, - extrapolation=None, grid=False): + @overload + def __init__( + self, + eval_points: ArrayLike, + *, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[False] = False, + ) -> None: + pass + + @overload + def __init__( + self, + eval_points: GridPointsLike, + *, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[True], + ) -> None: + pass + + def __init__( + self, + eval_points: Union[ArrayLike, GridPointsLike, None] = None, + *, + extrapolation: Optional[ExtrapolationLike] = None, + grid: bool = False, + ): self.eval_points = eval_points self.extrapolation = extrapolation self.grid = grid - def fit(self, X: FData, y=None): + def fit( # noqa: D102 + self, + X: FData, + y: None = None, + ) -> EvaluationTransformer: if self.eval_points is None and not isinstance(X, FDataGrid): - raise ValueError("If no eval_points are passed, the functions " - "should be FDataGrid objects.") + raise ValueError( + "If no eval_points are passed, the functions " + "should be FDataGrid objects.", + ) self._is_fitted = True return self - def transform(self, X, y=None): + def transform( # noqa: D102 + self, + X: FData, + y: None = None, + ) -> np.ndarray: check_is_fitted(self, '_is_fitted') if self.eval_points is None: evaluation = X.data_matrix.copy() else: - evaluation = X(self.eval_points, - extrapolation=self.extrapolation, grid=self.grid) - - evaluation = evaluation.reshape((X.n_samples, -1)) + evaluation = X( # type: ignore + self.eval_points, + extrapolation=self.extrapolation, + grid=self.grid, + ) - return evaluation + return evaluation.reshape((X.n_samples, -1)) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 5843778fb..22ac35a8a 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -4,6 +4,8 @@ objects of the package and contains some commons methods. """ +from __future__ import annotations + import warnings from abc import ABC, abstractmethod from typing import ( @@ -14,25 +16,39 @@ NoReturn, Optional, Sequence, - Tuple, TypeVar, Union, + cast, + overload, ) import numpy as np import pandas.api.extensions - -from .._utils import _evaluate_grid, _reshape_eval_points +from typing_extensions import Literal + +from .._utils import _evaluate_grid, _reshape_eval_points, _to_grid_points +from ._typing import ( + ArrayLike, + DomainRange, + GridPointsLike, + LabelTuple, + LabelTupleLike, +) from .evaluator import Evaluator -from .extrapolation import _parse_extrapolation +from .extrapolation import ExtrapolationLike, _parse_extrapolation if TYPE_CHECKING: from . import FDataGrid, FDataBasis from .basis import Basis T = TypeVar('T', bound='FData') -DomainRange = Tuple[Tuple[float, float], ...] -LabelTuple = Tuple[Optional[str], ...] + +EvalPointsType = Union[ + ArrayLike, + Iterable[ArrayLike], + GridPointsLike, + Iterable[GridPointsLike], +] class FData( # noqa: WPS214 @@ -57,16 +73,16 @@ class FData( # noqa: WPS214 def __init__( self, *, - extrapolation: Evaluator, + extrapolation: Optional[ExtrapolationLike] = None, dataset_name: Optional[str] = None, dataset_label: Optional[str] = None, - axes_labels: Optional[LabelTuple] = None, - argument_names: Optional[LabelTuple] = None, - coordinate_names: Optional[LabelTuple] = None, - sample_names: Optional[LabelTuple] = None, + axes_labels: Optional[LabelTupleLike] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, ) -> None: - self.extrapolation = extrapolation + self.extrapolation = extrapolation # type: ignore self.dataset_name = dataset_name if dataset_label is not None: @@ -75,7 +91,7 @@ def __init__( self.argument_names = argument_names # type: ignore self.coordinate_names = coordinate_names # type: ignore if axes_labels is not None: - self.axes_labels = axes_labels + self.axes_labels = axes_labels # type: ignore self.sample_names = sample_names # type: ignore @property @@ -103,7 +119,7 @@ def argument_names(self) -> LabelTuple: @argument_names.setter def argument_names( self, - names: Optional[LabelTuple], + names: Optional[LabelTupleLike], ) -> None: if names is None: names = (None,) * self.dim_domain @@ -124,7 +140,7 @@ def coordinate_names(self) -> LabelTuple: @coordinate_names.setter def coordinate_names( self, - names: Optional[LabelTuple], + names: Optional[LabelTupleLike], ) -> None: if names is None: names = (None,) * self.dim_codomain @@ -150,7 +166,7 @@ def axes_labels(self) -> LabelTuple: return self.argument_names + self.coordinate_names @axes_labels.setter - def axes_labels(self, labels: LabelTuple) -> None: + def axes_labels(self, labels: LabelTupleLike) -> None: """Set the list of labels.""" if labels is not None: @@ -182,7 +198,7 @@ def sample_names(self) -> LabelTuple: return self._sample_names @sample_names.setter - def sample_names(self, names: Optional[LabelTuple]) -> None: + def sample_names(self, names: Optional[LabelTupleLike]) -> None: if names is None: names = (None,) * self.n_samples else: @@ -229,7 +245,7 @@ def dim_codomain(self) -> int: @property @abstractmethod - def coordinates(self: T) -> T: + def coordinates(self: T) -> Sequence[T]: r"""Return a component of the FDataGrid. If the functional object contains multivariate samples @@ -245,7 +261,7 @@ def extrapolation(self) -> Optional[Evaluator]: return self._extrapolation @extrapolation.setter - def extrapolation(self, value: Optional[Union[str, Evaluator]]) -> None: + def extrapolation(self, value: Optional[ExtrapolationLike]) -> None: """Set the type of extrapolation.""" self._extrapolation = _parse_extrapolation(value) @@ -274,7 +290,7 @@ def _extrapolation_index(self, eval_points: np.ndarray) -> np.ndarray: should be applied. """ - index = np.zeros(eval_points.shape[:-1], dtype=np.bool) + index = np.zeros(eval_points.shape[:-1], dtype=np.bool_) # Checks bounds in each domain dimension for i, bounds in enumerate(self.domain_range): @@ -331,7 +347,7 @@ def _join_evaluation( @abstractmethod def _evaluate( self, - eval_points: np.ndarray, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], *, aligned: bool = True, ) -> np.ndarray: @@ -358,12 +374,60 @@ def _evaluate( """ pass + @overload + def evaluate( + self, + eval_points: ArrayLike, + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[False] = False, + aligned: Literal[True] = True, + ) -> np.ndarray: + pass + + @overload def evaluate( self, - eval_points: np.ndarray, + eval_points: Iterable[ArrayLike], *, derivative: int = 0, - extrapolation: Optional[Union[str, Evaluator]] = None, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[False] = False, + aligned: Literal[False], + ) -> np.ndarray: + pass + + @overload + def evaluate( + self, + eval_points: GridPointsLike, + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[True], + aligned: Literal[True] = True, + ) -> np.ndarray: + pass + + @overload + def evaluate( + self, + eval_points: Iterable[GridPointsLike], + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[True], + aligned: Literal[False], + ) -> np.ndarray: + pass + + def evaluate( + self, + eval_points: EvalPointsType, + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, grid: bool = False, aligned: bool = True, ) -> np.ndarray: @@ -401,7 +465,7 @@ def evaluate( "derivative function instead.", DeprecationWarning, ) - return self.derivative(order=derivative)( + return self.derivative(order=derivative)( # type: ignore eval_points, extrapolation=extrapolation, grid=grid, @@ -409,7 +473,8 @@ def evaluate( ) if grid: # Evaluation of a grid performed in auxiliar function - return _evaluate_grid( + + return _evaluate_grid( # type: ignore eval_points, evaluate_method=self.evaluate, n_samples=self.n_samples, @@ -419,12 +484,19 @@ def evaluate( aligned=aligned, ) + eval_points = cast(Union[ArrayLike, Iterable[ArrayLike]], eval_points) + if extrapolation is None: extrapolation = self.extrapolation else: # Gets the function to perform extrapolation or None extrapolation = _parse_extrapolation(extrapolation) + eval_points = cast( + Union[ArrayLike, Sequence[ArrayLike]], + eval_points, + ) + # Convert to array and check dimensions of eval points eval_points = _reshape_eval_points( eval_points, @@ -461,7 +533,7 @@ def evaluate( aligned=aligned, ) - res_extrapolation = extrapolation.evaluate( + res_extrapolation = extrapolation( # type: ignore self, eval_points_extrapolation, aligned=aligned, @@ -480,12 +552,60 @@ def evaluate( aligned=aligned, ) + @overload + def __call__( + self, + eval_points: ArrayLike, + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[False] = False, + aligned: Literal[True] = True, + ) -> np.ndarray: + pass + + @overload + def __call__( + self, + eval_points: Iterable[ArrayLike], + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[False] = False, + aligned: Literal[False], + ) -> np.ndarray: + pass + + @overload + def __call__( + self, + eval_points: GridPointsLike, + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[True], + aligned: Literal[True] = True, + ) -> np.ndarray: + pass + + @overload + def __call__( + self, + eval_points: Iterable[GridPointsLike], + *, + derivative: int = 0, + extrapolation: Optional[ExtrapolationLike] = None, + grid: Literal[True], + aligned: Literal[False], + ) -> np.ndarray: + pass + def __call__( self, - eval_points: np.ndarray, + eval_points: EvalPointsType, *, derivative: int = 0, - extrapolation: Optional[Union[str, Evaluator]] = None, + extrapolation: Optional[ExtrapolationLike] = None, grid: bool = False, aligned: bool = True, ) -> np.ndarray: @@ -519,7 +639,7 @@ def __call__( function at the values specified in eval_points. """ - return self.evaluate( + return self.evaluate( # type: ignore eval_points, derivative=derivative, extrapolation=extrapolation, @@ -528,7 +648,7 @@ def __call__( ) @abstractmethod - def derivative(self: T, order: int = 1) -> T: + def derivative(self: T, *, order: int = 1) -> T: """Differentiate a FData object. Args: @@ -542,40 +662,111 @@ def derivative(self: T, order: int = 1) -> T: @abstractmethod def shift( - self: T, - shifts: Union[float, np.ndarray], + self, + shifts: Union[ArrayLike, float], *, restrict_domain: bool = False, - extrapolation: Optional[Union[str, Evaluator]] = None, - eval_points: np.ndarray = None, - **kwargs: Any, - ) -> T: - """Perform a shift of the curves. + extrapolation: Optional[ExtrapolationLike] = None, + grid_points: Optional[GridPointsLike] = None, + ) -> FDataGrid: + r""" + Perform a shift of the curves. + + The i-th shifted function :math:`y_i` has the form + + .. math:: + y_i(t) = x_i(t + \delta_i) + + where :math:`x_i` is the i-th original function and :math:`delta_i` is + the shift performed for that function, that must be a vector in the + domain space. + + Note that a positive shift moves the graph of the function in the + negative direction and vice versa. Args: - shifts: List with the shift corresponding - for each sample or numeric with the shift to apply to all - samples. - restrict_domain: If True restricts the domain to - avoid evaluate points outside the domain using extrapolation. + shifts: List with the shifts + corresponding for each sample or numeric with the shift to + apply to all samples. + restrict_domain: If True restricts the domain to avoid the + evaluation of points outside the domain using extrapolation. Defaults uses extrapolation. extrapolation: Controls the extrapolation mode for elements outside the domain range. By default uses the method defined in fd. See extrapolation to more information. - eval_points: Set of points where + grid_points: Grid of points where the functions are evaluated to obtain the discrete - representation of the object to operate. If an empty list is - passed it calls np.linspace with bounds equal to the ones - defined in fd.domain_range and the number of points the maximum - between 201 and 10 times the number of basis plus 1. - kwargs: Additional arguments. + representation of the object to operate. If ``None`` the + current grid_points are used to unificate the domain of the + shifted data. Returns: - :class:`FData` with the shifted functional data. + Shifted functions. """ - pass + assert grid_points is not None + grid_points = _to_grid_points(grid_points) + + arr_shifts = np.array([shifts] if np.isscalar(shifts) else shifts) + + # Accept unidimensional array when the domain dimension is one or when + # the shift is the same for each sample + if arr_shifts.ndim == 1: + arr_shifts = ( + arr_shifts[np.newaxis, :] # Same shift for each sample + if len(arr_shifts) == self.dim_domain + else arr_shifts[:, np.newaxis] + ) + + if len(arr_shifts) not in {1, self.n_samples}: + raise ValueError( + f"The length of the shift vector ({len(arr_shifts)}) must " + f"have length equal to 1 or to the number of samples " + f"({self.n_samples})", + ) + + if restrict_domain: + domain = np.asarray(self.domain_range) + + a = domain[:, 0] - np.min(np.min(arr_shifts, axis=0), 0) + b = domain[:, 1] - np.max(np.max(arr_shifts, axis=1), 0) + + domain = np.hstack((a, b)) + domain_range = tuple(domain) + + if len(arr_shifts) == 1: + shifted_grid_points = tuple( + g + s for g, s in zip(grid_points, arr_shifts[0]) + ) + data_matrix = self( + shifted_grid_points, + extrapolation=extrapolation, + aligned=True, + grid=True, + ) + else: + shifted_grid_points_per_sample = ( + tuple( + g + s for g, s in zip(grid_points, shift) + ) for shift in arr_shifts + ) + data_matrix = self( + shifted_grid_points_per_sample, + extrapolation=extrapolation, + aligned=False, + grid=True, + ) + + shifted = self.to_grid().copy( + data_matrix=data_matrix, + grid_points=grid_points, + ) + + if restrict_domain: + shifted = shifted.restrict(domain_range) + + return shifted def plot(self, *args: Any, **kwargs: Any) -> Any: """Plot the FDatGrid object. @@ -588,28 +779,29 @@ def plot(self, *args: Any, **kwargs: Any) -> Any: fig (figure object): figure object in which the graphs are plotted. """ - from ..exploratory.visualization.representation import plot_graph + from ..exploratory.visualization.representation import GraphPlot - return plot_graph(self, *args, **kwargs) + return GraphPlot(self, *args, **kwargs).plot() @abstractmethod - def copy(self: T, **kwargs: Any) -> T: - """Make a copy of the object. - - Args: - kwargs: named args with attributes to be changed in the new copy. - - Returns: - A copy of the FData object. - - """ + def copy( + self: T, + *, + deep: bool = False, # For Pandas compatibility + dataset_name: Optional[str] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, + extrapolation: Optional[ExtrapolationLike] = None, + ) -> T: + """Make a copy of the object.""" pass @abstractmethod # noqa: WPS125 def sum( # noqa: WPS125 self: T, *, - axis: None = None, + axis: Optional[int] = None, out: None = None, keepdims: bool = False, skipna: bool = False, @@ -676,7 +868,10 @@ def mean( ) @abstractmethod - def to_grid(self, grid_points: np.ndarray = None) -> 'FDataGrid': + def to_grid( + self, + grid_points: Optional[GridPointsLike] = None, + ) -> FDataGrid: """Return the discrete representation of the object. Args: @@ -693,9 +888,9 @@ def to_grid(self, grid_points: np.ndarray = None) -> 'FDataGrid': @abstractmethod def to_basis( self, - basis: 'Basis', + basis: Basis, **kwargs: Any, - ) -> 'FDataBasis': + ) -> FDataBasis: """Return the basis representation of the object. Args: @@ -736,9 +931,8 @@ def compose( self: T, fd: T, *, - eval_points: np.ndarray = None, - **kwargs: Any, - ) -> T: + eval_points: Optional[np.ndarray] = None, + ) -> FData: """Composition of functions. Performs the composition of functions. @@ -748,18 +942,16 @@ def compose( have the same number of samples and image dimension equal to the domain dimension of the object composed. eval_points: Points to perform the evaluation. - kwargs: Named arguments to be passed to the composition method of - the specific functional object. """ pass @abstractmethod - def __getitem__(self: T, key: Union[int, slice]) -> T: + def __getitem__(self: T, key: Union[int, slice, np.ndarray]) -> T: """Return self[key].""" pass - def equals(self, other: Any) -> bool: + def equals(self, other: object) -> bool: """Whole object equality.""" return ( isinstance(other, type(self)) # noqa: WPS222 @@ -770,10 +962,10 @@ def equals(self, other: Any) -> bool: ) @abstractmethod - def __eq__(self, other: Any) -> np.ndarray: + def __eq__(self, other: object) -> np.ndarray: # type: ignore[override] pass - def __ne__(self, other: Any) -> np.ndarray: + def __ne__(self, other: object) -> np.ndarray: # type: ignore[override] """Return for `self != other` (element-wise in-equality).""" result = self.__eq__(other) if result is NotImplemented: diff --git a/skfda/representation/_typing.py b/skfda/representation/_typing.py new file mode 100644 index 000000000..881ef6530 --- /dev/null +++ b/skfda/representation/_typing.py @@ -0,0 +1,64 @@ +"""Common types.""" +from typing import Any, Optional, Sequence, Tuple, TypeVar, Union + +import numpy as np +from typing_extensions import Protocol + +try: + from numpy.typing import ArrayLike +except ImportError: + ArrayLike = np.ndarray # type:ignore + +try: + from numpy.typing import NDArray + NDArrayAny = NDArray[Any] + NDArrayInt = NDArray[np.int_] + NDArrayFloat = NDArray[np.float_] + NDArrayBool = NDArray[np.bool_] +except ImportError: + NDArray = np.ndarray # type:ignore + NDArrayAny = np.ndarray # type:ignore + NDArrayInt = np.ndarray # type:ignore + NDArrayFloat = np.ndarray # type:ignore + NDArrayBool = np.ndarray # type:ignore + +VectorType = TypeVar("VectorType") + +DomainRange = Tuple[Tuple[float, float], ...] +DomainRangeLike = Union[ + DomainRange, + Sequence[float], + Sequence[Sequence[float]], +] + +LabelTuple = Tuple[Optional[str], ...] +LabelTupleLike = Sequence[Optional[str]] + +GridPoints = Tuple[np.ndarray, ...] +GridPointsLike = Union[ArrayLike, Sequence[ArrayLike]] + + +class Vector(Protocol): + """ + Protocol representing a generic vector. + + It should accept numpy arrays and FData, among other things. + """ + + def __add__( + self: VectorType, + __other: VectorType, # noqa: WPS112 + ) -> VectorType: + pass + + def __sub__( + self: VectorType, + __other: VectorType, # noqa: WPS112 + ) -> VectorType: + pass + + def __mul__( + self: VectorType, + __other: float, # noqa: WPS112 + ) -> VectorType: + pass diff --git a/skfda/representation/basis/__init__.py b/skfda/representation/basis/__init__.py index 7b2fa39e7..28c891554 100644 --- a/skfda/representation/basis/__init__.py +++ b/skfda/representation/basis/__init__.py @@ -3,6 +3,7 @@ from ._coefficients_transformer import CoefficientsTransformer from ._constant import Constant from ._fdatabasis import FDataBasis, FDataBasisDType +from ._finite_element import FiniteElement from ._fourier import Fourier from ._monomial import Monomial from ._tensor_basis import Tensor diff --git a/skfda/representation/basis/_basis.py b/skfda/representation/basis/_basis.py index e6e666e5f..d5ba326b9 100644 --- a/skfda/representation/basis/_basis.py +++ b/skfda/representation/basis/_basis.py @@ -1,51 +1,44 @@ -"""Module for functional data manipulation in a basis system. +"""Abstract base class for basis.""" -Defines functional data object in a basis function system representation and -the corresponding basis classes. +from __future__ import annotations -""" import copy import warnings from abc import ABC, abstractmethod -from typing import Tuple +from typing import TYPE_CHECKING, Any, Optional, Tuple, TypeVar, Union import numpy as np +from matplotlib.figure import Figure -from ..._utils import _domain_range, _reshape_eval_points, _same_domain -from . import _fdatabasis +from ..._utils import _reshape_eval_points, _same_domain, _to_domain_range +from .._typing import ArrayLike, DomainRange, DomainRangeLike +if TYPE_CHECKING: + from . import FDataBasis -def _check_domain(domain_range): - for domain in domain_range: - if len(domain) != 2 or domain[0] >= domain[1]: - raise ValueError(f"The interval {domain} is not well-defined.") +T = TypeVar("T", bound='Basis') class Basis(ABC): - """Defines the structure of a basis function system. + """Defines the structure of a basis of functions. - Attributes: - domain_range (tuple): a tuple of length 2 containing the initial and - end values of the interval over which the basis can be evaluated. - n_basis (int): number of functions in the basis. + Parameters: + domain_range: The :term:`domain range` over which the basis can be + evaluated. + n_basis: number of functions in the basis. """ - def __init__(self, *, domain_range=None, n_basis: int = 1): - """Basis constructor. - - Args: - domain_range (tuple or list of tuples, optional): Definition of the - interval where the basis defines a space. Defaults to (0,1). - n_basis: Number of functions that form the basis. Defaults to 1. - - """ + def __init__( + self, + *, + domain_range: Optional[DomainRangeLike] = None, + n_basis: int = 1, + ) -> None: + """Basis constructor.""" if domain_range is not None: - domain_range = _domain_range(domain_range) - - # Some checks - _check_domain(domain_range) + domain_range = _to_domain_range(domain_range) if n_basis < 1: raise ValueError( @@ -57,43 +50,97 @@ def __init__(self, *, domain_range=None, n_basis: int = 1): super().__init__() - def __call__(self, *args, **kwargs) -> np.ndarray: - """Evaluate the basis using :meth:`evaluate`.""" - return self.evaluate(*args, **kwargs) + def __call__( + self, + eval_points: ArrayLike, + *, + derivative: int = 0, + ) -> np.ndarray: + """Evaluate Basis objects. + + Evaluates the basis functions at a list of given values. + + Args: + eval_points: List of points where the basis is + evaluated. + derivative: order of the derivative. + + .. deprecated:: 0.4 + Use `derivative` method instead. + + Returns: + Matrix whose rows are the values of the each + basis function or its derivatives at the values specified in + eval_points. + + """ + return self.evaluate(eval_points, derivative=derivative) @property def dim_domain(self) -> int: - return 1 + if self._domain_range is None: + return 1 + return len(self._domain_range) @property def dim_codomain(self) -> int: return 1 @property - def domain_range(self) -> Tuple[Tuple[float, float], ...]: + def domain_range(self) -> DomainRange: if self._domain_range is None: return ((0, 1),) * self.dim_domain - else: - return self._domain_range + + return self._domain_range @property def n_basis(self) -> int: return self._n_basis + def is_domain_range_fixed(self) -> bool: + """ + Return wether the :term:`domain range` has been set explicitly. + + This is useful when using a basis for converting a dataset, since + if this is not explicitly assigned it can be changed to the domain of + the data. + + Returns: + `True` if the domain range has been fixed. `False` otherwise. + + """ + return self._domain_range is not None + @abstractmethod - def _evaluate(self, eval_points) -> np.ndarray: - """Subclasses must override this to provide basis evaluation.""" + def _evaluate( + self, + eval_points: np.ndarray, + ) -> np.ndarray: + """ + Evaluate Basis object. + + Subclasses must override this to provide basis evaluation. + + """ pass - def evaluate(self, eval_points, *, derivative: int = 0) -> np.ndarray: + def evaluate( + self, + eval_points: ArrayLike, + *, + derivative: int = 0, + ) -> np.ndarray: """Evaluate Basis objects and its derivatives. - Evaluates the basis function system or its derivatives at a list of - given values. + Evaluates the basis functions at a list of given values. Args: - eval_points (array_like): List of points where the basis is + eval_points: List of points where the basis is evaluated. + derivative: order of the derivative. + + .. deprecated:: 0.4 + Use `derivative` method instead. Returns: Matrix whose rows are the values of the each @@ -104,22 +151,28 @@ def evaluate(self, eval_points, *, derivative: int = 0) -> np.ndarray: if derivative < 0: raise ValueError("derivative only takes non-negative values.") elif derivative != 0: - warnings.warn("Parameter derivative is deprecated. Use the " - "derivative function instead.", DeprecationWarning) + warnings.warn( + "Parameter derivative is deprecated. Use the " + "derivative method instead.", + DeprecationWarning, + ) return self.derivative(order=derivative)(eval_points) - eval_points = _reshape_eval_points(eval_points, - aligned=True, - n_samples=self.n_basis, - dim_domain=self.dim_domain) + eval_points = _reshape_eval_points( + eval_points, + aligned=True, + n_samples=self.n_basis, + dim_domain=self.dim_domain, + ) return self._evaluate(eval_points).reshape( - (self.n_basis, len(eval_points), self.dim_codomain)) + (self.n_basis, len(eval_points), self.dim_codomain), + ) def __len__(self) -> int: return self.n_basis - def derivative(self, *, order: int = 1) -> '_fdatabasis.FDataBasis': + def derivative(self, *, order: int = 1) -> FDataBasis: """Construct a FDataBasis object containing the derivative. Args: @@ -129,53 +182,83 @@ def derivative(self, *, order: int = 1) -> '_fdatabasis.FDataBasis': Derivative object. """ - return self.to_basis().derivative(order=order) - def _derivative_basis_and_coefs(self, coefs: np.ndarray, order: int = 1): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: """ + Return basis and coefficients of the derivative. + + Args: + coefs: Coefficients of a vector expressed in this basis. + order: Order of the derivative. + + Returns: + Tuple with the basis of the derivative and its coefficients. + Subclasses can override this to provide derivative construction. - A basis can provide derivative evaluation at given points - without providing a basis representation for its derivatives, - although is recommended to provide both if possible. + """ + raise NotImplementedError( + f"{type(self)} basis does not support the construction of a " + "basis of the derivatives.", + ) + + def derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: + """ + Return basis and coefficients of the derivative. + + Args: + coefs: Coefficients of a vector expressed in this basis. + order: Order of the derivative. + + Returns: + Tuple with the basis of the derivative and its coefficients. """ - raise NotImplementedError(f"{type(self)} basis does not support " - "the construction of a basis of the " - "derivatives.") + return self._derivative_basis_and_coefs(coefs, order) - def plot(self, chart=None, **kwargs): + def plot(self, *args: Any, **kwargs: Any) -> Figure: """Plot the basis object or its derivatives. Args: - chart (figure object, axe or list of axes, optional): figure over - with the graphs are plotted or axis over where the graphs are - plotted. - **kwargs: keyword arguments to be passed to the + args: arguments to be passed to the + fdata.plot function. + kwargs: keyword arguments to be passed to the fdata.plot function. Returns: - fig (figure): figure object in which the graphs are plotted. + Figure object in which the graphs are plotted. """ - self.to_basis().plot(chart=chart, **kwargs) + self.to_basis().plot(*args, **kwargs) - def _coordinate_nonfull(self, fdatabasis, key): + def _coordinate_nonfull( + self, + coefs: np.ndarray, + key: Union[int, slice], + ) -> Tuple[Basis, np.ndarray]: """ - Returns a fdatagrid for the coordinate functions indexed by key. + Return a basis and coefficients for the indexed coordinate functions. Subclasses can override this to provide coordinate indexing. - The key parameter has been already validated and is an integer or - slice in the range [0, self.dim_codomain. - """ raise NotImplementedError("Coordinate indexing not implemented") - def _coordinate(self, fdatabasis, key): - """Returns a fdatagrid for the coordinate functions indexed by key.""" - + def coordinate_basis_and_coefs( + self, + coefs: np.ndarray, + key: Union[int, slice], + ) -> Tuple[Basis, np.ndarray]: + """Return a fdatabasis for the coordinate functions indexed by key.""" # Raises error if not in range and normalize key r_key = range(self.dim_codomain)[key] @@ -183,44 +266,46 @@ def _coordinate(self, fdatabasis, key): raise IndexError("Empty number of coordinates selected") # Full fdatabasis case - if (self.dim_codomain == 1 and r_key == 0) or ( - isinstance(r_key, range) and len(r_key) == self.dim_codomain): - - return fdatabasis.copy() - - else: + if ( + (self.dim_codomain == 1 and r_key == 0) + or (isinstance(r_key, range) and len(r_key) == self.dim_codomain) + ): + return self, np.copy(coefs) + + return self._coordinate_nonfull( + coefs=coefs, + key=key, + ) + + def rescale(self: T, domain_range: Optional[DomainRangeLike] = None) -> T: + """ + Return a copy of the basis with a new :term:`domain` range. - return self._coordinate_nonfull(fdatabasis=fdatabasis, key=r_key) + Args: + domain_range: Definition of the interval + where the basis defines a space. Defaults uses the same as + the original basis. - def rescale(self, domain_range=None): - r"""Return a copy of the basis with a new :term:`domain` range, with - the corresponding values rescaled to the new bounds. + Returns: + Rescaled copy. - Args: - domain_range (tuple, optional): Definition of the interval - where the basis defines a space. Defaults uses the same as - the original basis. """ - return self.copy(domain_range=domain_range) - def copy(self, domain_range=None): - """Basis copy""" - + def copy(self: T, domain_range: Optional[DomainRangeLike] = None) -> T: + """Basis copy.""" new_copy = copy.deepcopy(self) if domain_range is not None: - domain_range = _domain_range(domain_range) + domain_range = _to_domain_range(domain_range) - # Some checks - _check_domain(domain_range) - - new_copy._domain_range = domain_range + new_copy._domain_range = domain_range # noqa: WPS437 return new_copy - def to_basis(self) -> '_fdatabasis.FDataBasis': - """Convert the Basis to FDatabasis. + def to_basis(self) -> FDataBasis: + """ + Convert the Basis to FDatabasis. Returns: FDataBasis with this basis as its basis, and all basis functions @@ -230,24 +315,22 @@ def to_basis(self) -> '_fdatabasis.FDataBasis': from . import FDataBasis return FDataBasis(self.copy(), np.identity(self.n_basis)) - def _list_to_R(self, knots): - retstring = "c(" - for i in range(0, len(knots)): - retstring = retstring + str(knots[i]) + ", " - return retstring[0:len(retstring) - 2] + ")" - - def _to_R(self): + def _to_R(self) -> str: # noqa: N802 raise NotImplementedError - def inner_product_matrix(self, other: 'Basis' = None) -> np.array: - r"""Return the Inner Product Matrix of a pair of basis. + def inner_product_matrix( + self, + other: Optional[Basis] = None, + ) -> np.ndarray: + r""" + Return the Inner Product Matrix of a pair of basis. The Inner Product Matrix is defined as .. math:: - IP_{ij} = \langle\phi_i, \theta_j\rangle + I_{ij} = \langle\phi_i, \theta_j\rangle - where :math:`\phi_i` is the ith element of the basi and + where :math:`\phi_i` is the ith element of the basis and :math:`\theta_j` is the jth element of the second basis. This matrix helps on the calculation of the inner product between objects on two basis and for the change of basis. @@ -268,16 +351,13 @@ def inner_product_matrix(self, other: 'Basis' = None) -> np.array: return inner_product_matrix(self, other) - def _gram_matrix_numerical(self) -> np.array: - """ - Compute the Gram matrix numerically. - - """ + def _gram_matrix_numerical(self) -> np.ndarray: + """Compute the Gram matrix numerically.""" from ...misc import inner_product_matrix return inner_product_matrix(self, force_numerical=True) - def _gram_matrix(self) -> np.array: + def _gram_matrix(self) -> np.ndarray: """ Compute the Gram matrix. @@ -287,8 +367,9 @@ def _gram_matrix(self) -> np.array: """ return self._gram_matrix_numerical() - def gram_matrix(self) -> np.array: - r"""Return the Gram Matrix of a basis + def gram_matrix(self) -> np.ndarray: + r""" + Return the Gram Matrix of a basis. The Gram Matrix is defined as @@ -302,7 +383,6 @@ def gram_matrix(self) -> np.array: Gram Matrix of the basis. """ - gram = getattr(self, "_gram_matrix_cached", None) if gram is None: @@ -311,44 +391,33 @@ def gram_matrix(self) -> np.array: return gram - def _add_same_basis(self, coefs1, coefs2): - return self.copy(), coefs1 + coefs2 - - def _add_constant(self, coefs, constant): - coefs = coefs.copy() - constant = np.array(constant) - coefs[:, 0] = coefs[:, 0] + constant - - return self.copy(), coefs - - def _sub_same_basis(self, coefs1, coefs2): - return self.copy(), coefs1 - coefs2 - - def _sub_constant(self, coefs, other): - coefs = coefs.copy() - other = np.array(other) - coefs[:, 0] = coefs[:, 0] - other - - return self.copy(), coefs - - def _mul_constant(self, coefs, other): + def _mul_constant( + self: T, + coefs: np.ndarray, + other: float, + ) -> Tuple[T, np.ndarray]: coefs = coefs.copy() other = np.atleast_2d(other).reshape(-1, 1) - coefs = coefs * other + coefs *= other return self.copy(), coefs def __repr__(self) -> str: """Representation of a Basis object.""" - return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"n_basis={self.n_basis})") - - def __eq__(self, other) -> bool: - """Equality of Basis""" - return (type(self) == type(other) - and _same_domain(self, other) - and self.n_basis == other.n_basis) + return ( + f"{self.__class__.__name__}(" + f"domain_range={self.domain_range}, " + f"n_basis={self.n_basis})" + ) + + def __eq__(self, other: Any) -> bool: + """Test equality of Basis.""" + return ( + isinstance(other, type(self)) + and _same_domain(self, other) + and self.n_basis == other.n_basis + ) def __hash__(self) -> int: - """Hash of Basis""" + """Hash a Basis.""" return hash((self.domain_range, self.n_basis)) diff --git a/skfda/representation/basis/_bspline.py b/skfda/representation/basis/_bspline.py index 791699398..9054de46e 100644 --- a/skfda/representation/basis/_bspline.py +++ b/skfda/representation/basis/_bspline.py @@ -1,11 +1,16 @@ +from typing import Any, Optional, Sequence, Tuple, Type, TypeVar + import numpy as np import scipy.interpolate from numpy import polyint, polymul, polyval from scipy.interpolate import BSpline as SciBSpline, PPoly -from ..._utils import _domain_range +from ..._utils import _to_domain_range +from .._typing import DomainRangeLike from ._basis import Basis +T = TypeVar("T", bound='BSpline') + class BSpline(Basis): r"""BSpline basis. @@ -27,12 +32,12 @@ class BSpline(Basis): boundaries [RS05]_. This is automatically done so that the user only has to specify a single knot at the boundaries. - Attributes: - domain_range (tuple): A tuple of length 2 containing the initial and + Parameters: + domain_range: A tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - n_basis (int): Number of functions in the basis. - order (int): Order of the splines. One greather than their degree. - knots (list): List of knots of the spline functions. + n_basis: Number of functions in the basis. + order: Order of the splines. One greather than their degree. + knots: List of knots of the spline functions. Examples: Constructs specifying number of basis and order. @@ -83,25 +88,16 @@ class BSpline(Basis): """ - def __init__(self, domain_range=None, n_basis=None, order=4, knots=None): - """Bspline basis constructor. - - Args: - domain_range (tuple, optional): Definition of the interval where - the basis defines a space. Defaults to (0,1) if knots are not - specified. If knots are specified defaults to the first and - last element of the knots. - n_basis (int, optional): Number of splines that form the basis. - order (int, optional): Order of the splines. One greater that - their degree. Defaults to 4 which mean cubic splines. - knots (array_like): List of knots of the splines. If domain_range - is specified the first and last elements of the knots have to - match with it. - - """ - + def __init__( + self, + domain_range: Optional[DomainRangeLike] = None, + n_basis: Optional[int] = None, + order: int = 4, + knots: Optional[Sequence[float]] = None, + ) -> None: + """Bspline basis constructor.""" if domain_range is not None: - domain_range = _domain_range(domain_range) + domain_range = _to_domain_range(domain_range) if len(domain_range) != 1: raise ValueError("Domain range should be unidimensional.") @@ -109,28 +105,32 @@ def __init__(self, domain_range=None, n_basis=None, order=4, knots=None): domain_range = domain_range[0] # Knots default to equally space points in the domain_range - if knots is None: - if n_basis is None: - raise ValueError("Must provide either a list of knots or the" - "number of basis.") - else: + if knots is not None: knots = tuple(knots) knots = sorted(knots) if domain_range is None: domain_range = (knots[0], knots[-1]) - else: - if domain_range[0] != knots[0] or domain_range[1] != knots[-1]: - raise ValueError("The ends of the knots must be the same " - "as the domain_range.") + elif domain_range[0] != knots[0] or domain_range[1] != knots[-1]: + raise ValueError( + "The ends of the knots must be the same " + "as the domain_range.", + ) # n_basis default to number of knots + order of the splines - 2 if n_basis is None: + if knots is None: + raise ValueError( + "Must provide either a list of knots or the" + "number of basis.", + ) n_basis = len(knots) + order - 2 if (n_basis - order + 2) < 2: - raise ValueError(f"The number of basis ({n_basis}) minus the " - f"order of the bspline ({order}) should be " - f"greater than 3.") + raise ValueError( + f"The number of basis ({n_basis}) minus the " + f"order of the bspline ({order}) should be " + f"greater than 3.", + ) self._order = order self._knots = None if knots is None else tuple(knots) @@ -138,35 +138,43 @@ def __init__(self, domain_range=None, n_basis=None, order=4, knots=None): # Checks if self.n_basis != self.order + len(self.knots) - 2: - raise ValueError(f"The number of basis ({self.n_basis}) has to " - f"equal the order ({self.order}) plus the " - f"number of knots ({len(self.knots)}) minus 2.") + raise ValueError( + f"The number of basis ({self.n_basis}) has to " + f"equal the order ({self.order}) plus the " + f"number of knots ({len(self.knots)}) minus 2.", + ) @property - def knots(self): + def knots(self) -> Tuple[float, ...]: if self._knots is None: - return tuple(np.linspace(*self.domain_range[0], - self.n_basis - self.order + 2)) - else: - return self._knots + return tuple(np.linspace( + *self.domain_range[0], + self.n_basis - self.order + 2, + )) + + return self._knots @property - def order(self): + def order(self) -> int: return self._order - def _evaluation_knots(self): + def _evaluation_knots(self) -> Tuple[float, ...]: """ - Get the knots adding m knots to the boundary in order to allow a - discontinuous behaviour at the boundaries of the domain [RS05]_. + Get the knots adding m knots to the boundary. + + This needs to be done in order to allow a discontinuous behaviour + at the boundaries of the domain [RS05]_. References: .. [RS05] Ramsay, J., Silverman, B. W. (2005). *Functional Data Analysis*. Springer. 50-51. """ - return np.array((self.knots[0],) * (self.order - 1) + self.knots + - (self.knots[-1],) * (self.order - 1)) + return tuple( + (self.knots[0],) * (self.order - 1) + self.knots + + (self.knots[-1],) * (self.order - 1), + ) - def _evaluate(self, eval_points): + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: # Input is scalar eval_points = eval_points[..., 0] @@ -186,45 +194,53 @@ def _evaluate(self, eval_points): # iteration c[i] = 1 # compute the spline - mat[i] = scipy.interpolate.splev(eval_points, - (knots, c, self.order - 1)) + mat[i] = scipy.interpolate.splev( + eval_points, + (knots, c, self.order - 1), + ) c[i] = 0 return mat - def _derivative_basis_and_coefs(self, coefs, order=1): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: if order >= self.order: return ( - BSpline(n_basis=1, domain_range=self.domain_range, order=1), - np.zeros((len(coefs), 1))) + type(self)(n_basis=1, domain_range=self.domain_range, order=1), + np.zeros((len(coefs), 1)), + ) - deriv_splines = [self._to_scipy_BSpline(coefs[i]).derivative(order) - for i in range(coefs.shape[0])] + deriv_splines = [ + self._to_scipy_bspline(coefs[i]).derivative(order) + for i in range(coefs.shape[0]) + ] - deriv_coefs = [BSpline._from_scipy_BSpline(spline)[1] - for spline in deriv_splines] + deriv_coefs = [ + self._from_scipy_bspline(spline)[1] + for spline in deriv_splines + ] - deriv_basis = BSpline._from_scipy_BSpline(deriv_splines[0])[0] + deriv_basis = self._from_scipy_bspline(deriv_splines[0])[0] return deriv_basis, np.array(deriv_coefs)[:, 0:deriv_basis.n_basis] - def rescale(self, domain_range=None): - r"""Return a copy of the basis with a new domain range, with the - corresponding values rescaled to the new bounds. - The knots of the BSpline will be rescaled in the new interval. - - Args: - domain_range (tuple, optional): Definition of the interval - where the basis defines a space. Defaults uses the same as - the original basis. - """ + def rescale( # noqa: D102 + self: T, + domain_range: Optional[DomainRangeLike] = None, + ) -> T: knots = np.array(self.knots, dtype=np.dtype('float')) if domain_range is not None: # Rescales the knots + domain_range = _to_domain_range(domain_range)[0] knots -= knots[0] - knots *= ((domain_range[1] - domain_range[0] - ) / (self.knots[-1] - self.knots[0])) + knots *= ( + (domain_range[1] - domain_range[0]) + / (self.knots[-1] - self.knots[0]) + ) knots += domain_range[0] # Fix possible round error @@ -235,15 +251,17 @@ def rescale(self, domain_range=None): # TODO: Allow multiple dimensions domain_range = self.domain_range[0] - return BSpline(domain_range, self.n_basis, self.order, knots) + return type(self)(domain_range, self.n_basis, self.order, knots) - def __repr__(self): + def __repr__(self) -> str: """Representation of a BSpline basis.""" - return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"n_basis={self.n_basis}, order={self.order}, " - f"knots={self.knots})") + return ( + f"{self.__class__.__name__}(domain_range={self.domain_range}, " + f"n_basis={self.n_basis}, order={self.order}, " + f"knots={self.knots})" + ) - def _gram_matrix(self): + def _gram_matrix(self) -> np.ndarray: # Places m knots at the boundaries knots = self._evaluation_knots() @@ -257,11 +275,11 @@ def _gram_matrix(self): no_0_intervals = np.where(np.diff(knots) > 0)[0] # For each basis gets its piecewise polynomial representation - for i in range(self.n_basis): + for n in range(self.n_basis): # Write a 1 in c in the position of the spline # transformed in each iteration - c[i] = 1 + c[n] = 1 # Gets the piecewise polynomial representation and gets # only the positions for no zero length intervals @@ -276,24 +294,29 @@ def _gram_matrix(self): # (x - a), so we will need to subtract a when computing the # definite integral ppoly_lst.append(pp_coefs) - c[i] = 0 + c[n] = 0 # Now for each pair of basis computes the inner product after # applying the linear differential operator matrix = np.zeros((self.n_basis, self.n_basis)) - for interval in range(len(no_0_intervals)): + for interval, _ in enumerate(no_0_intervals): for i in range(self.n_basis): - poly_i = np.trim_zeros(ppoly_lst[i][:, - interval], 'f') + poly_i = np.trim_zeros( + ppoly_lst[i][:, interval], + 'f', + ) # Indefinite integral square = polymul(poly_i, poly_i) integral = polyint(square) # Definite integral - matrix[i, i] += np.diff(polyval( - integral, self.knots[interval: interval + 2] - - self.knots[interval]))[0] + matrix[i, i] += np.diff( + polyval( + integral, np.array(self.knots[interval: interval + 2]) + - self.knots[interval], + ), + )[0] # The Gram matrix is banded, so not all intervals are used for j in range(i + 1, min(i + self.order, self.n_basis)): @@ -303,9 +326,12 @@ def _gram_matrix(self): integral = polyint(polymul(poly_i, poly_j)) # Definite integral - matrix[i, j] += np.diff(polyval( - integral, self.knots[interval: interval + 2] - - self.knots[interval]) + matrix[i, j] += np.diff( + polyval( + integral, + np.array(self.knots[interval: interval + 2]) + - self.knots[interval], + ), )[0] # The matrix is symmetric @@ -313,17 +339,21 @@ def _gram_matrix(self): return matrix - def _to_scipy_BSpline(self, coefs): + def _to_scipy_bspline(self, coefs: np.ndarray) -> SciBSpline: knots = np.concatenate(( np.repeat(self.knots[0], self.order - 1), self.knots, - np.repeat(self.knots[-1], self.order - 1))) + np.repeat(self.knots[-1], self.order - 1), + )) return SciBSpline(knots, coefs, self.order - 1) - @staticmethod - def _from_scipy_BSpline(bspline): + @classmethod + def _from_scipy_bspline( + cls: Type[T], + bspline: SciBSpline, + ) -> Tuple[T, np.ndarray]: order = bspline.k knots = bspline.t @@ -334,17 +364,14 @@ def _from_scipy_BSpline(bspline): coefs = bspline.c domain_range = [knots[0], knots[-1]] - return BSpline(domain_range, order=order + 1, knots=knots), coefs - - @property - def inknots(self): - """Return number of basis.""" - return self.knots[1:len(self.knots) - 1] + return cls(domain_range, order=order + 1, knots=knots), coefs - def __eq__(self, other): - return (super().__eq__(other) - and self.order == other.order - and self.knots == other.knots) + def __eq__(self, other: Any) -> bool: + return ( + super().__eq__(other) + and self.order == other.order + and self.knots == other.knots + ) - def __hash__(self): + def __hash__(self) -> int: return hash((super().__hash__(), self.order, self.knots)) diff --git a/skfda/representation/basis/_coefficients_transformer.py b/skfda/representation/basis/_coefficients_transformer.py index 073c2eb63..1f5f42db6 100644 --- a/skfda/representation/basis/_coefficients_transformer.py +++ b/skfda/representation/basis/_coefficients_transformer.py @@ -1,15 +1,21 @@ +from __future__ import annotations + +import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted from ._fdatabasis import FDataBasis -class CoefficientsTransformer(BaseEstimator, TransformerMixin): - """ +class CoefficientsTransformer( + BaseEstimator, # type:ignore + TransformerMixin, # type:ignore +): + r""" Transformer returning the coefficients of FDataBasis objects as a matrix. Attributes: - shape_ (tuple): original shape of coefficients per sample. + basis\_ (tuple): Basis used. Examples: >>> from skfda.representation.basis import (FDataBasis, Monomial, @@ -26,19 +32,24 @@ class CoefficientsTransformer(BaseEstimator, TransformerMixin): """ - def fit(self, X: FDataBasis, y=None): + def fit( # noqa: D102 + self, + X: FDataBasis, + y: None = None, + ) -> CoefficientsTransformer: - self.shape_ = X.coefficients.shape[1:] + self.basis_ = X.basis return self - def transform(self, X, y=None): + def transform( # noqa: D102 + self, + X: FDataBasis, + y: None = None, + ) -> np.ndarray: check_is_fitted(self) - assert X.coefficients.shape[1:] == self.shape_ - - coefficients = X.coefficients.copy() - coefficients = coefficients.reshape((X.n_samples, -1)) + assert X.basis == self.basis_ - return coefficients + return X.coefficients.copy() diff --git a/skfda/representation/basis/_constant.py b/skfda/representation/basis/_constant.py index 220adc8b6..3d49af323 100644 --- a/skfda/representation/basis/_constant.py +++ b/skfda/representation/basis/_constant.py @@ -1,17 +1,21 @@ +from typing import Optional, Tuple, TypeVar + import numpy as np -from ..._utils import _same_domain +from .._typing import DomainRangeLike from ._basis import Basis +T = TypeVar("T", bound='Constant') + class Constant(Basis): """Constant basis. Basis for constant functions - Attributes: - domain_range (tuple): a tuple of length 2 containing the initial and - end values of the interval over which the basis can be evaluated. + Parameters: + domain_range: The :term:`domain range` over which the basis can be + evaluated. Examples: Defines a contant base over the interval :math:`[0, 5]` consisting @@ -21,28 +25,29 @@ class Constant(Basis): """ - def __init__(self, domain_range=None): - """Constant basis constructor. - - Args: - domain_range (tuple): Tuple defining the domain over which the - function is defined. - - """ + def __init__(self, domain_range: Optional[DomainRangeLike] = None) -> None: + """Constant basis constructor.""" super().__init__(domain_range=domain_range, n_basis=1) - def _evaluate(self, eval_points): + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: return np.ones((1, len(eval_points))) - def _derivative_basis_and_coefs(self, coefs, order=1): - return ((self.copy(), coefs.copy()) if order == 0 - else (self.copy(), np.zeros(coefs.shape))) - - def _gram_matrix(self): - return np.array([[self.domain_range[0][1] - - self.domain_range[0][0]]]) - - def _to_R(self): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: + return ( + (self.copy(), coefs.copy()) if order == 0 + else (self.copy(), np.zeros(coefs.shape)) + ) + + def _gram_matrix(self) -> np.ndarray: + return np.array( + [[self.domain_range[0][1] - self.domain_range[0][0]]], + ) + + def _to_R(self) -> str: # noqa: N802 drange = self.domain_range[0] - return "create.constant.basis(rangeval = c(" + str(drange[0]) + "," +\ - str(drange[1]) + "))" + drange_str = f"c({str(drange[0])}, {str(drange[1])})" + return f"create.constant.basis(rangeval = {drange_str})" diff --git a/skfda/representation/basis/_fdatabasis.py b/skfda/representation/basis/_fdatabasis.py index 7c324bad7..a9071e7f4 100644 --- a/skfda/representation/basis/_fdatabasis.py +++ b/skfda/representation/basis/_fdatabasis.py @@ -1,22 +1,39 @@ +from __future__ import annotations + import copy -import numbers import warnings from builtins import isinstance -from typing import Any +from typing import ( + TYPE_CHECKING, + Any, + Iterable, + Optional, + Sequence, + Type, + TypeVar, + Union, + cast, +) import numpy as np import pandas.api.extensions +from skfda._utils._utils import _to_array_maybe_ragged + from ..._utils import _check_array_key, _int_to_real, constants from .. import grid from .._functional_data import FData +from .._typing import ArrayLike, DomainRange, GridPointsLike, LabelTupleLike +from ..extrapolation import ExtrapolationLike +from . import Basis +if TYPE_CHECKING: + from .. import FDataGrid -def _same_domain(one_domain_range, other_domain_range): - return np.array_equal(one_domain_range, other_domain_range) +T = TypeVar('T', bound='FDataBasis') -class FDataBasis(FData): +class FDataBasis(FData): # noqa: WPS214 r"""Basis representation of functional data. Class representation for functional data in the form of a set of basis @@ -30,21 +47,21 @@ class FDataBasis(FData): ..., \phi_K)` the basis function system. Attributes: - basis (:obj:`Basis`): Basis function system. - coefficients (numpy.darray): List or matrix of coefficients. Has to + basis: Basis function system. + coefficients: List or matrix of coefficients. Has to have the same length or number of columns as the number of basis function in the basis. If a matrix, each row contains the coefficients that multiplied by the basis functions produce each functional datum. - domain_range (numpy.ndarray): 2 dimension matrix where each row + domain_range: 2 dimension matrix where each row contains the bounds of the interval in which the functional data is considered to exist for each one of the axies. - dataset_name (str): name of the dataset. - argument_names (tuple): tuple containing the names of the different + dataset_name: name of the dataset. + argument_names: tuple containing the names of the different arguments. - coordinate_names (tuple): tuple containing the names of the different + coordinate_names: tuple containing the names of the different coordinate functions. - extrapolation (str or Extrapolation): defines the default type of + extrapolation: defines the default type of extrapolation. By default None, which does not apply any type of extrapolation. See `Extrapolation` for detailled information of the types of extrapolation. @@ -61,67 +78,51 @@ class FDataBasis(FData): ...) """ - class _CoordinateIterator: - """Internal class to iterate through the image coordinates. - - Dummy object. Should be change to support multidimensional objects. - - """ - - def __init__(self, fdatabasis): - """Create an iterator through the image coordinates.""" - self._fdatabasis = fdatabasis - - def __iter__(self): - """Return an iterator through the image coordinates.""" - for i in range(len(self)): - yield self[i] - - def __getitem__(self, key): - """Get a specific coordinate.""" - - return self._fdatabasis.basis._coordinate(self._fdatabasis, key) - - def __len__(self): - """Return the number of coordinates.""" - return self._fdatabasis.dim_codomain - - def __init__(self, basis, coefficients, *, dataset_label=None, - dataset_name=None, - axes_labels=None, argument_names=None, - coordinate_names=None, - sample_names=None, - extrapolation=None): - """Construct a FDataBasis object. - - Args: - basis (:obj:`Basis`): Basis function system. - coefficients (array_like): List or matrix of coefficients. Has to - have the same length or number of columns as the number of - basis function in the basis. - """ + def __init__( + self, + basis: Basis, + coefficients: ArrayLike, + *, + dataset_label: Optional[str] = None, + dataset_name: Optional[str] = None, + axes_labels: Optional[LabelTupleLike] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, + extrapolation: Optional[ExtrapolationLike] = None, + ) -> None: + """Construct a FDataBasis object.""" coefficients = _int_to_real(np.atleast_2d(coefficients)) if coefficients.shape[1] != basis.n_basis: - raise ValueError("The length or number of columns of coefficients " - "has to be the same equal to the number of " - "elements of the basis.") + raise ValueError( + "The length or number of columns of coefficients " + "has to be the same equal to the number of " + "elements of the basis.", + ) self.basis = basis self.coefficients = coefficients - super().__init__(extrapolation=extrapolation, - dataset_label=dataset_label, - dataset_name=dataset_name, - axes_labels=axes_labels, - argument_names=argument_names, - coordinate_names=coordinate_names, - sample_names=sample_names) + super().__init__( + extrapolation=extrapolation, + dataset_label=dataset_label, + dataset_name=dataset_name, + axes_labels=axes_labels, + argument_names=argument_names, + coordinate_names=coordinate_names, + sample_names=sample_names, + ) @classmethod - def from_data(cls, data_matrix, *, basis, - grid_points=None, - sample_points=None, - method='cholesky'): + def from_data( + cls, + data_matrix: np.ndarray, + *, + basis: Basis, + grid_points: Optional[GridPointsLike] = None, + sample_points: Optional[GridPointsLike] = None, + method: str = 'cholesky', + ) -> FDataBasis: r"""Transform raw data to a smooth functional form. Takes functional data in a discrete form and makes an approximates it @@ -155,16 +156,20 @@ def from_data(cls, data_matrix, *, basis, [RS05-5-2-7]_ Args: - data_matrix (array_like): List or matrix containing the + data_matrix: List or matrix containing the observations. If a matrix each row represents a single functional datum and the columns the different observations. - grid_points (array_like): Values of the domain where the previous + grid_points: Values of the domain where the previous data were taken. - basis: (Basis): Basis used. - method (str): Algorithm used for calculating the coefficients using + basis: Basis used. + method: Algorithm used for calculating the coefficients using the least squares method. The values admitted are 'cholesky' and 'qr' for Cholesky and QR factorisation methods respectively. + sample_points: Old name for `grid_points`. New code should + use `grid_points` instead. + + .. deprecated:: 0.5 Returns: FDataBasis: Represention of the data in a functional form as @@ -193,12 +198,12 @@ def from_data(cls, data_matrix, *, basis, Data Analysis* (pp. 86-87). Springer. """ - from ..grid import FDataGrid - if sample_points is not None: - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) grid_points = sample_points # n is the samples @@ -208,158 +213,129 @@ def from_data(cls, data_matrix, *, basis, # Each sample in a column (m x n) data_matrix = np.atleast_2d(data_matrix) - fd = FDataGrid(data_matrix=data_matrix, grid_points=grid_points) + fd = grid.FDataGrid(data_matrix=data_matrix, grid_points=grid_points) return fd.to_basis(basis=basis, method=method) @property - def n_samples(self): - return self.coefficients.shape[0] + def n_samples(self) -> int: + return len(self.coefficients) @property - def dim_domain(self): + def dim_domain(self) -> int: return self.basis.dim_domain @property - def dim_codomain(self): + def dim_codomain(self) -> int: return self.basis.dim_codomain @property - def coordinates(self): + def coordinates(self: T) -> _CoordinateIterator[T]: r"""Return a component of the FDataBasis. If the functional object contains samples :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this object allows a component of the vector :math:`f = (f_1, ..., f_d)`. - - Todo: - By the moment, only unidimensional objects are supported in basis - form. - """ - - return FDataBasis._CoordinateIterator(self) + return _CoordinateIterator(self) @property - def n_basis(self): + def n_basis(self) -> int: """Return number of basis.""" return self.basis.n_basis @property - def domain_range(self): + def domain_range(self) -> DomainRange: return self.basis.domain_range - def _evaluate(self, eval_points, *, aligned=True): + def _evaluate( + self, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: if aligned: + eval_points = np.asarray(eval_points) + # Each row contains the values of one element of the basis basis_values = self.basis.evaluate(eval_points) res = np.tensordot(self.coefficients, basis_values, axes=(1, 0)) return res.reshape( - (self.n_samples, len(eval_points), self.dim_codomain)) + (self.n_samples, len(eval_points), self.dim_codomain), + ) - else: + eval_points = cast(Iterable[ArrayLike], eval_points) - res_matrix = np.empty( - (self.n_samples, eval_points.shape[1], self.dim_codomain)) + res_list = [ + np.sum((c * self.basis.evaluate(np.asarray(p)).T).T, axis=0) + for c, p in zip(self.coefficients, eval_points) + ] - for i in range(self.n_samples): - basis_values = self.basis.evaluate(eval_points[i]) + return _to_array_maybe_ragged(res_list) - values = self.coefficients[i] * basis_values.T - np.sum(values.T, axis=0, out=res_matrix[i]) + def shift( + self, + shifts: Union[ArrayLike, float], + *, + restrict_domain: bool = False, + extrapolation: Optional[ExtrapolationLike] = None, + grid_points: Optional[GridPointsLike] = None, + ) -> FDataGrid: + r""" + Perform a shift of the curves. - return res_matrix + The i-th shifted function :math:`y_i` has the form - def shift(self, shifts, *, restrict_domain=False, extrapolation=None, - eval_points=None, **kwargs): - r"""Perform a shift of the curves. + .. math:: + y_i(t) = x_i(t + \delta_i) + + where :math:`x_i` is the i-th original function and :math:`delta_i` is + the shift performed for that function, that must be a vector in the + domain space. + + Note that a positive shift moves the graph of the function in the + negative direction and vice versa. Args: - shifts (array_like or numeric): List with the the shift + shifts: List with the shifts corresponding for each sample or numeric with the shift to apply to all samples. - restrict_domain (bool, optional): If True restricts the domain to - avoid evaluate points outside the domain using extrapolation. + restrict_domain: If True restricts the domain to avoid the + evaluation of points outside the domain using extrapolation. Defaults uses extrapolation. - extrapolation (str or Extrapolation, optional): Controls the + extrapolation: Controls the extrapolation mode for elements outside the domain range. By default uses the method defined in fd. See extrapolation to more information. - eval_points (array_like, optional): Set of points where + grid_points: Grid of points where the functions are evaluated to obtain the discrete - representation of the object to operate. If an empty list is - passed it calls numpy.linspace with bounds equal to the ones - defined in fd.domain_range and the number of points the maximum - between 201 and 10 times the number of basis plus 1. - **kwargs: Keyword arguments to be passed to :meth:`from_data`. + representation of the object to operate. If ``None`` the + current grid_points are used to unificate the domain of the + shifted data. Returns: - :obj:`FDataBasis` with the shifted data. - """ + Shifted functions. - if self.dim_codomain > 1 or self.dim_domain > 1: - raise ValueError - - domain_range = self.domain_range[0] - - if eval_points is None: # Grid to discretize the function - nfine = max(self.n_basis * 10 + 1, constants.N_POINTS_COARSE_MESH) - eval_points = np.linspace(*domain_range, nfine) - else: - eval_points = np.asarray(eval_points) - - if np.isscalar(shifts): # Special case, all curves with same shift - - _basis = self.basis.rescale((domain_range[0] + shifts, - domain_range[1] + shifts)) - - return FDataBasis.from_data(self.evaluate(eval_points), - grid_points=eval_points + shifts, - basis=_basis, **kwargs) - - elif len(shifts) != self.n_samples: - raise ValueError(f"shifts vector ({len(shifts)}) must have the " - f"same length than the number of samples " - f"({self.n_samples})") - - if restrict_domain: - a = domain_range[0] - min(np.min(shifts), 0) - b = domain_range[1] - max(np.max(shifts), 0) - domain = (a, b) - eval_points = eval_points[ - np.logical_and(eval_points >= a, - eval_points <= b)] - else: - domain = domain_range - - points_shifted = np.outer(np.ones(self.n_samples), - eval_points) - - points_shifted += np.atleast_2d(shifts).T - - # Matrix of shifted values - _data_matrix = self(points_shifted, - aligned=False, - extrapolation=extrapolation)[..., 0] - - _basis = self.basis.rescale(domain) - - return FDataBasis.from_data(_data_matrix, grid_points=eval_points, - basis=_basis, **kwargs) + """ + grid_points = ( + self._default_grid_points() if grid_points is None + else grid_points + ) - def derivative(self, *, order=1): - r"""Differentiate a FDataBasis object. + return super().shift( + shifts=shifts, + restrict_domain=restrict_domain, + extrapolation=extrapolation, + grid_points=grid_points, + ) - - Args: - order (int, optional): Order of the derivative. Defaults to one. - """ + def derivative(self: T, *, order: int = 1) -> T: # noqa: D102 if order < 0: raise ValueError("order only takes non-negative integer values.") @@ -367,22 +343,39 @@ def derivative(self, *, order=1): if order == 0: return self.copy() - basis, coefficients = self.basis._derivative_basis_and_coefs( - self.coefficients, order) - - return FDataBasis(basis, coefficients) - - def sum(self, *, axis=None, out=None, keepdims=False, skipna=False, - min_count=0): + basis, coefficients = self.basis.derivative_basis_and_coefs( + self.coefficients, + order, + ) + + return self.copy(basis=basis, coefficients=coefficients) + + def sum( # noqa: WPS125 + self: T, + *, + axis: Optional[int] = None, + out: None = None, + keepdims: bool = False, + skipna: bool = False, + min_count: int = 0, + ) -> T: """Compute the sum of all the samples in a FDataBasis object. + Args: + axis: Used for compatibility with numpy. Must be None or 0. + out: Used for compatibility with numpy. Must be None. + keepdims: Used for compatibility with numpy. Must be False. + skipna: Wether the NaNs are ignored or not. + min_count: Number of valid (non NaN) data to have in order + for the a variable to not be NaN when `skipna` is + `True`. + Returns: - :obj:`FDataBasis`: A FDataBais object with just one sample + A FDataBais object with just one sample representing the sum of all the samples in the original FDataBasis object. Examples: - >>> from skfda.representation.basis import FDataBasis, Monomial >>> basis = Monomial(n_basis=4) >>> coefficients = [[0.5, 1, 2, .5], [1.5, 1, 4, .5]] @@ -395,18 +388,22 @@ def sum(self, *, axis=None, out=None, keepdims=False, skipna=False, """ super().sum(axis=axis, out=out, keepdims=keepdims, skipna=skipna) - coefs = (np.nansum(self.coefficients, axis=0) if skipna - else np.sum(self.coefficients, axis=0)) + coefs = ( + np.nansum(self.coefficients, axis=0) if skipna + else np.sum(self.coefficients, axis=0) + ) if min_count > 0: valid = ~np.isnan(self.coefficients) n_valid = np.sum(valid, axis=0) coefs[n_valid < min_count] = np.NaN - return self.copy(coefficients=coefs, - sample_names=(None,)) + return self.copy( + coefficients=coefs, + sample_names=(None,), + ) - def gmean(self, eval_points=None): + def gmean(self: T, eval_points: Optional[np.ndarray] = None) -> T: """Compute the geometric mean of the functional data object. A numerical approach its used. The object its transformed into its @@ -414,7 +411,7 @@ def gmean(self, eval_points=None): then the object is taken back to the basis representation. Args: - eval_points (array_like, optional): Set of points where the + eval_points: Set of points where the functions are evaluated to obtain the discrete representation of the object. If none are passed it calls numpy.linspace with bounds equal to the ones defined in @@ -422,12 +419,12 @@ def gmean(self, eval_points=None): between 501 and 10 times the number of basis. Returns: - FDataBasis: Geometric mean of the original object. + Geometric mean of the original object. """ return self.to_grid(eval_points).gmean().to_basis(self.basis) - def var(self, eval_points=None): + def var(self: T, eval_points: Optional[np.ndarray] = None) -> T: """Compute the variance of the functional data object. A numerical approach its used. The object its transformed into its @@ -435,7 +432,7 @@ def var(self, eval_points=None): then the object is taken back to the basis representation. Args: - eval_points (array_like, optional): Set of points where the + eval_points: Set of points where the functions are evaluated to obtain the discrete representation of the object. If none are passed it calls numpy.linspace with bounds equal to the ones defined in @@ -443,19 +440,19 @@ def var(self, eval_points=None): between 501 and 10 times the number of basis. Returns: - FDataBasis: Variance of the original object. + Variance of the original object. """ return self.to_grid(eval_points).var().to_basis(self.basis) - def cov(self, eval_points=None): + def cov(self, eval_points: Optional[np.ndarray] = None) -> FData: """Compute the covariance of the functional data object. A numerical approach its used. The object its transformed into its discrete representation and then the covariance matrix is computed. Args: - eval_points (array_like, optional): Set of points where the + eval_points: Set of points where the functions are evaluated to obtain the discrete representation of the object. If none are passed it calls numpy.linspace with bounds equal to the ones defined in @@ -463,12 +460,17 @@ def cov(self, eval_points=None): between 501 and 10 times the number of basis. Returns: - numpy.darray: Matrix of covariances. + Matrix of covariances. """ return self.to_grid(eval_points).cov() - def to_grid(self, grid_points=None, *, sample_points=None): + def to_grid( + self, + grid_points: Optional[GridPointsLike] = None, + *, + sample_points: Optional[GridPointsLike] = None, + ) -> FDataGrid: """Return the discrete representation of the object. Args: @@ -477,13 +479,16 @@ def to_grid(self, grid_points=None, *, sample_points=None): numpy.linspace with bounds equal to the ones defined in self.domain_range and the number of points the maximum between 501 and 10 times the number of basis. + sample_points: Old name for `grid_points`. New code should + use `grid_points` instead. + + .. deprecated:: 0.5 Returns: FDataGrid: Discrete representation of the functional data object. Examples: - >>> from skfda.representation.basis import FDataBasis, Monomial >>> fd = FDataBasis(coefficients=[[1, 1, 1], [1, 0, 1]], ... basis=Monomial(domain_range=(0,5), n_basis=3)) @@ -501,48 +506,61 @@ def to_grid(self, grid_points=None, *, sample_points=None): """ if sample_points is not None: - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) grid_points = sample_points if grid_points is None: - npoints = max(constants.N_POINTS_FINE_MESH, - constants.BASIS_MIN_FACTOR * self.n_basis) - grid_points = [np.linspace(*r, npoints) - for r in self.domain_range] - - return grid.FDataGrid(self.evaluate(grid_points, grid=True), - grid_points=grid_points, - domain_range=self.domain_range) - - def to_basis(self, basis, eval_points=None, **kwargs): - """Return the basis representation of the object. + grid_points = self._default_grid_points() + + return grid.FDataGrid( + self.evaluate(grid_points, grid=True), + grid_points=grid_points, + domain_range=self.domain_range, + ) + + def to_basis( + self, + basis: Optional[Basis] = None, + eval_points: Optional[np.ndarray] = None, + **kwargs: Any, + ) -> FDataBasis: + """ + Return the basis representation of the object. Args: - basis(Basis): basis object in which the functional data are + basis: Basis object in which the functional data are going to be represented. - **kwargs: keyword arguments to be passed to + eval_points: Evaluation points used to discretize the function + if the basis is going to be changed. + kwargs: Keyword arguments to be passed to FDataBasis.from_data(). Returns: - FDataBasis: Basis representation of the funtional data - object. - """ + Basis representation of the funtional data object. - if basis == self.basis: + """ + if basis is None or basis == self.basis: return self.copy() - return self.to_grid(eval_points=eval_points).to_basis(basis, **kwargs) - - def copy(self, *, basis=None, coefficients=None, - dataset_name=None, - argument_names=None, - coordinate_names=None, - sample_names=None, - extrapolation=None): - """FDataBasis copy""" - + return self.to_grid(grid_points=eval_points).to_basis(basis, **kwargs) + + def copy( + self: T, + *, + deep: bool = False, # For Pandas compatibility + basis: Optional[Basis] = None, + coefficients: Optional[np.ndarray] = None, + dataset_name: Optional[str] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, + extrapolation: Optional[ExtrapolationLike] = None, + ) -> T: + """Copy the FDataBasis.""" if basis is None: basis = copy.deepcopy(self.basis) @@ -567,92 +585,124 @@ def copy(self, *, basis=None, coefficients=None, if extrapolation is None: extrapolation = self.extrapolation - return FDataBasis(basis, coefficients, - dataset_name=dataset_name, - argument_names=argument_names, - coordinate_names=coordinate_names, - sample_names=sample_names, - extrapolation=extrapolation) - - def _to_R(self): - """Gives the code to build the object on fda package on R""" - return ("fd(coef = " + self._array_to_R(self.coefficients, True) + - ", basisobj = " + self.basis._to_R() + ")") - - def _array_to_R(self, coefficients, transpose=False): + return FDataBasis( + basis, + coefficients, + dataset_name=dataset_name, + argument_names=argument_names, + coordinate_names=coordinate_names, + sample_names=sample_names, + extrapolation=extrapolation, + ) + + def _default_grid_points(self) -> GridPointsLike: + npoints = constants.N_POINTS_FINE_MESH + return [ + np.linspace(*r, npoints) + for r in self.domain_range + ] + + def _to_R(self) -> str: # noqa: N802 + """Return the code to build the object on fda package on R.""" + return ( + f"fd(" # noqa: WPS437 + f"coef = {self._array_to_R(self.coefficients, transpose=True)}," + f" basisobj = {self.basis._to_R()})" + ) + + def _array_to_R( # noqa: N802 + self, + coefficients: np.ndarray, + transpose: bool = False, + ) -> str: if len(coefficients.shape) == 1: coefficients = coefficients.reshape((1, coefficients.shape[0])) - if len(coefficients.shape) > 2: - return NotImplementedError - if transpose is True: coefficients = np.transpose(coefficients) (rows, cols) = coefficients.shape retstring = "matrix(c(" - for j in range(cols): - for i in range(rows): - retstring = retstring + str(coefficients[i, j]) + ", " - - return (retstring[0:len(retstring) - 2] + "), nrow = " + str(rows) + - ", ncol = " + str(cols) + ")") - - def __repr__(self): - """Representation of FDataBasis object.""" - - return (f"{self.__class__.__name__}(" - f"\nbasis={self.basis}," - f"\ncoefficients={self.coefficients}," - f"\ndataset_name={self.dataset_name}," - f"\nargument_names={repr(self.argument_names)}," - f"\ncoordinate_names={repr(self.coordinate_names)}," - f"\nextrapolation={self.extrapolation})").replace( - '\n', '\n ') - - def __str__(self): - """Return str(self).""" - - return (f"{self.__class__.__name__}(" - f"\n_basis={self.basis}," - f"\ncoefficients={self.coefficients})").replace('\n', '\n ') - - def equals(self, other): - """Equality of FDataBasis""" + retstring += "".join( + f"{coefficients[i, j]}, " + for j in range(cols) + for i in range(rows) + ) + + return ( + retstring[:len(retstring) - 2] + + f"), nrow = {rows}, ncol = {cols})" + ) + + def __repr__(self) -> str: + + return ( + f"{self.__class__.__name__}(" # noqa: WPS221 + f"\nbasis={self.basis}," + f"\ncoefficients={self.coefficients}," + f"\ndataset_name={self.dataset_name}," + f"\nargument_names={repr(self.argument_names)}," + f"\ncoordinate_names={repr(self.coordinate_names)}," + f"\nextrapolation={self.extrapolation})" + ).replace('\n', '\n ') + + def __str__(self) -> str: + + return ( + f"{self.__class__.__name__}(" + f"\n_basis={self.basis}," + f"\ncoefficients={self.coefficients})" + ).replace('\n', '\n ') + + def equals(self, other: object) -> bool: + """Equality of FDataBasis.""" # TODO check all other params - return (super().equals(other) - and self.basis == other.basis - and np.array_equal(self.coefficients, other.coefficients)) - def __eq__(self, other): - """Elementwise equality of FDataBasis""" + if not super().equals(other): + return False + + other = cast(grid.FDataGrid, other) + + return ( + self.basis == other.basis + and np.array_equal(self.coefficients, other.coefficients) + ) - if not isinstance(self, type(other)) or self.dtype != other.dtype: + def __eq__(self, other: object) -> np.ndarray: # type: ignore[override] + """Elementwise equality of FDataBasis.""" + if not isinstance(other, type(self)) or self.dtype != other.dtype: if other is pandas.NA: return self.isna() if pandas.api.types.is_list_like(other) and not isinstance( other, (pandas.Series, pandas.Index, pandas.DataFrame), ): return np.concatenate([x == y for x, y in zip(self, other)]) - else: - return NotImplemented + + return NotImplemented if len(self) != len(other) and len(self) != 1 and len(other) != 1: - raise ValueError(f"Different lengths: " - f"len(self)={len(self)} and " - f"len(other)={len(other)}") + raise ValueError( + f"Different lengths: " + f"len(self)={len(self)} and " + f"len(other)={len(other)}", + ) return np.all(self.coefficients == other.coefficients, axis=1) - def concatenate(self, *others, as_coordinates=False): - """Join samples from a similar FDataBasis object. + def concatenate( + self: T, + *others: T, + as_coordinates: bool = False, + ) -> T: + """ + Join samples from a similar FDataBasis object. Joins samples from another FDataBasis object if they have the same basis. Args: - others (:class:`FDataBasis`): Objects to be concatenated. - as_coordinates (boolean, optional): If False concatenates as + others: Objects to be concatenated. + as_coordinates: If False concatenates as new samples, else, concatenates the other functions as new components of the image. Defaults to False. @@ -663,8 +713,8 @@ def concatenate(self, *others, as_coordinates=False): Todo: By the moment, only unidimensional objects are supported in basis representation. - """ + """ # TODO: Change to support multivariate functions # in basis representation if as_coordinates: @@ -676,13 +726,22 @@ def concatenate(self, *others, as_coordinates=False): data = [self.coefficients] + [other.coefficients for other in others] - sample_names = [fd.sample_names for fd in [self, *others]] + sample_names = [fd.sample_names for fd in (self, *others)] - return self.copy(coefficients=np.concatenate(data, axis=0), - sample_names=sum(sample_names, ())) + return self.copy( + coefficients=np.concatenate(data, axis=0), + sample_names=sum(sample_names, ()), + ) - def compose(self, fd, *, eval_points=None, **kwargs): - """Composition of functions. + def compose( + self, + fd: FData, + *, + eval_points: Optional[np.ndarray] = None, + **kwargs: Any, + ) -> FData: + """ + Composition of functions. Performs the composition of functions. The basis is discretized to compute the composition. @@ -692,110 +751,123 @@ def compose(self, fd, *, eval_points=None, **kwargs): have the same number of samples and image dimension equal to 1. eval_points (array_like): Points to perform the evaluation. kwargs: Named arguments to be passed to :func:`from_data`. - """ - grid = self.to_grid().compose(fd, eval_points=eval_points) + Returns: + Function resulting from the composition. + + """ + fd_grid = self.to_grid().compose(fd, eval_points=eval_points) if fd.dim_domain == 1: basis = self.basis.rescale(fd.domain_range[0]) - composition = grid.to_basis(basis, **kwargs) + composition = fd_grid.to_basis(basis, **kwargs) else: #  Cant be convertered to basis due to the dimensions - composition = grid + composition = fd_grid return composition - def __getitem__(self, key): + def __getitem__(self: T, key: Union[int, slice, np.ndarray]) -> T: """Return self[key].""" - key = _check_array_key(self.coefficients, key) - return self.copy(coefficients=self.coefficients[key], - sample_names=np.array(self.sample_names)[key]) + return self.copy( + coefficients=self.coefficients[key], + sample_names=np.array(self.sample_names)[key], + ) - def __add__(self, other): + def __add__(self: T, other: Union[T, np.ndarray, float]) -> T: """Addition for FDataBasis object.""" + if isinstance(other, FDataBasis) and self.basis == other.basis: - if isinstance(other, FDataBasis): - if self.basis != other.basis: - return NotImplemented - else: - basis, coefs = self.basis._add_same_basis(self.coefficients, - other.coefficients) - - else: - try: - basis, coefs = self.basis._add_constant(self.coefficients, - other) - except Exception: - return NotImplemented + return self._copy_op( + other, + basis=self.basis, + coefficients=self.coefficients + other.coefficients, + ) - return self._copy_op(other, basis=basis, coefficients=coefs) + return NotImplemented - def __radd__(self, other): + def __radd__(self: T, other: Union[T, np.ndarray, float]) -> T: """Addition for FDataBasis object.""" + if isinstance(other, FDataBasis) and self.basis == other.basis: - return self.__add__(other) + return self._copy_op( + other, + basis=self.basis, + coefficients=self.coefficients + other.coefficients, + ) - def __sub__(self, other): + return NotImplemented + + def __sub__(self: T, other: Union[T, np.ndarray, float]) -> T: """Subtraction for FDataBasis object.""" - if isinstance(other, FDataBasis): - if self.basis != other.basis: - return NotImplemented - else: - basis, coefs = self.basis._sub_same_basis(self.coefficients, - other.coefficients) - else: - try: - basis, coefs = self.basis._sub_constant(self.coefficients, - other) - except Exception: - return NotImplemented + if isinstance(other, FDataBasis) and self.basis == other.basis: - return self._copy_op(other, basis=basis, coefficients=coefs) + return self._copy_op( + other, + basis=self.basis, + coefficients=self.coefficients - other.coefficients, + ) - def __rsub__(self, other): + return NotImplemented + + def __rsub__(self: T, other: Union[T, np.ndarray, float]) -> T: """Right subtraction for FDataBasis object.""" - return (self * -1).__add__(other) + if isinstance(other, FDataBasis) and self.basis == other.basis: - def __mul__(self, other): - """Multiplication for FDataBasis object.""" - if isinstance(other, FDataBasis): - return NotImplemented + return self._copy_op( + other, + basis=self.basis, + coefficients=other.coefficients - self.coefficients, + ) + return NotImplemented + + def _mul_scalar(self: T, other: Union[np.ndarray, float]) -> T: + """Multiplication by scalar.""" try: - basis, coefs = self.basis._mul_constant(self.coefficients, other) + vector = np.atleast_1d(other) except Exception: return NotImplemented - return self._copy_op(other, basis=basis, coefficients=coefs) + if vector.ndim > 1: + return NotImplemented + + vector = vector[:, np.newaxis] - def __rmul__(self, other): - """Multiplication for FDataBasis object.""" - return self.__mul__(other) + return self._copy_op( + other, + basis=self.basis, + coefficients=self.coefficients * vector, + ) - def __truediv__(self, other): - """Division for FDataBasis object.""" + def __mul__(self: T, other: Union[np.ndarray, float]) -> T: + """Multiplication for FDataBasis object.""" + return self._mul_scalar(other) - other = np.array(other) + def __rmul__(self: T, other: Union[np.ndarray, float]) -> T: + """Multiplication for FDataBasis object.""" + return self._mul_scalar(other) + def __truediv__(self: T, other: Union[np.ndarray, float]) -> T: + """Division for FDataBasis object.""" try: - other = 1 / other + other = 1 / np.asarray(other) except Exception: return NotImplemented - return self * other + return self._mul_scalar(other) - def __rtruediv__(self, other): + def __rtruediv__(self: T, other: Union[np.ndarray, float]) -> T: """Right division for FDataBasis object.""" - return NotImplemented ##################################################################### # Pandas ExtensionArray methods ##################################################################### @property - def dtype(self): + def dtype(self) -> FDataBasisDType: """The dtype for this extension array, FDataGridDType""" return FDataBasisDType(basis=self.basis) @@ -806,9 +878,9 @@ def nbytes(self) -> int: """ return self.coefficients.nbytes - def isna(self): + def isna(self) -> np.ndarray: """ - A 1-D array indicating if each value is missing. + Return a 1-D array indicating if each value is missing. Returns: na_values (np.ndarray): Positions of NA. @@ -816,31 +888,33 @@ def isna(self): return np.all(np.isnan(self.coefficients), axis=1) -class FDataBasisDType(pandas.api.extensions.ExtensionDtype): - """ - DType corresponding to FDataBasis in Pandas - """ +class FDataBasisDType(pandas.api.extensions.ExtensionDtype): # type: ignore + """DType corresponding to FDataBasis in Pandas.""" + kind = 'O' - type = FDataBasis + type = FDataBasis # noqa: WPS125 name = 'FDataBasis' na_value = pandas.NA _metadata = ("basis") - def __init__(self, basis) -> None: + def __init__(self, basis: Basis) -> None: self.basis = basis @classmethod - def construct_array_type(cls) -> type: + def construct_array_type(cls) -> Type[FDataBasis]: # noqa: D102 return FDataBasis def _na_repr(self) -> FDataBasis: return FDataBasis( basis=self.basis, - coefficients=((np.NaN,) * self.basis.n_basis,)) + coefficients=((np.NaN,) * self.basis.n_basis,), + ) def __eq__(self, other: Any) -> bool: """ + Compare dtype equality. + Rules for equality (similar to categorical): 1) Any FData is equal to the string 'category' 2) Any FData is equal to itself @@ -851,9 +925,44 @@ def __eq__(self, other: Any) -> bool: return other == self.name elif other is self: return True - else: - return (isinstance(other, FDataBasisDType) - and self.basis == other.basis) + + return ( + isinstance(other, FDataBasisDType) + and self.basis == other.basis + ) def __hash__(self) -> int: return hash(self.basis) + + +class _CoordinateIterator(Sequence[T]): + """Internal class to iterate through the image coordinates. + + Dummy object. Should be change to support multidimensional objects. + + """ + + def __init__(self, fdatabasis: T) -> None: + """Create an iterator through the image coordinates.""" + self._fdatabasis = fdatabasis + + def __getitem__(self, key: Union[int, slice]) -> T: + """Get a specific coordinate.""" + basis, coefs = self._fdatabasis.basis.coordinate_basis_and_coefs( + self._fdatabasis.coefficients, + key, + ) + + coord_names = self._fdatabasis.coordinate_names[key] + if coord_names is None or isinstance(coord_names, str): + coord_names = (coord_names,) + + return self._fdatabasis.copy( + basis=basis, + coefficients=coefs, + coordinate_names=coord_names, + ) + + def __len__(self) -> int: + """Return the number of coordinates.""" + return self._fdatabasis.dim_codomain diff --git a/skfda/representation/basis/_finite_element.py b/skfda/representation/basis/_finite_element.py new file mode 100644 index 000000000..0e860c06f --- /dev/null +++ b/skfda/representation/basis/_finite_element.py @@ -0,0 +1,150 @@ +from typing import Optional, TypeVar + +import numpy as np + +from .._typing import ArrayLike, DomainRangeLike +from ._basis import Basis + +T = TypeVar("T", bound='FiniteElement') + + +class FiniteElement(Basis): + """Finite element basis. + + Given a n-dimensional grid made of simplices, each element of the basis + is a piecewise linear function that takes the value 1 at exactly one + vertex and 0 in the other vertices. + + Parameters: + vertices: The vertices of the grid. + cells: A list of individual cells, consisting in the indexes of + :math:`n+1` vertices for an n-dimensional domain space. + + Examples: + >>> from skfda.representation.basis import FiniteElement + >>> basis = FiniteElement( + ... vertices=[[0, 0], [0, 1], [1, 0], [1, 1]], + ... cells=[[0, 1, 2], [1, 2, 3]], + ... domain_range=[(0, 1), (0, 1)], + ... ) + + Evaluates all the functions in the basis in a list of discrete + values. + + >>> basis([[0.1, 0.1], [0.6, 0.6], [0.1, 0.2], [0.8, 0.9]]) + array([[[ 0.8], + [ 0. ], + [ 0.7], + [ 0. ]], + [[ 0.1], + [ 0.4], + [ 0.2], + [ 0.2]], + [[ 0.1], + [ 0.4], + [ 0.1], + [ 0.1]], + [[ 0. ], + [ 0.2], + [ 0. ], + [ 0.7]]]) + + + >>> from scipy.spatial import Delaunay + >>> import numpy as np + >>> + >>> n_points = 10 + >>> points = np.random.uniform(size=(n_points, 2)) + >>> delaunay = Delaunay(points) + >>> basis = FiniteElement( + ... vertices=delaunay.points, + ... cells=delaunay.simplices, + ... ) + >>> basis.n_basis + 10 + + """ + + def __init__( + self, + vertices: ArrayLike, + cells: ArrayLike, + domain_range: Optional[DomainRangeLike] = None, + ) -> None: + super().__init__( + domain_range=domain_range, + n_basis=len(vertices), + ) + self.vertices = np.asarray(vertices) + self.cells = np.asarray(cells) + + @property + def dim_domain(self) -> int: + return self.vertices.shape[-1] + + def _barycentric_coords(self, points: np.ndarray) -> np.ndarray: + """ + Find the barycentric coordinates of each point in each cell. + + Only works for simplex cells. + + """ + cell_coordinates = self.vertices[self.cells] + + cartesian_matrix = np.append( + cell_coordinates, + np.ones(cell_coordinates.shape[:-1] + (1,)), + axis=-1, + ) + + cartesian_vector = np.append( + points, + np.ones(points.shape[:-1] + (1,)), + axis=-1, + ) + + coords = np.linalg.solve( + np.swapaxes(cartesian_matrix, -2, -1), + cartesian_vector.T[np.newaxis, ...], + ) + + return np.swapaxes(coords, -2, -1) + + def _cell_points_values(self, points: np.ndarray) -> np.ndarray: + """ + Compute the values of each point in each of the vertices of each cell. + + Only works for simplex cells. + + """ + barycentric_coords = self._barycentric_coords(points) + + # Remove values outside each cell + wrong_vals = np.any( + ((barycentric_coords < 0) & ~np.isclose(barycentric_coords + 1, 1)) + | ((barycentric_coords > 1) & ~np.isclose(barycentric_coords, 1)), + axis=-1, + ) + + barycentric_coords[wrong_vals] = 0 + + points_in_cells = np.any(barycentric_coords, axis=-1) + n_cells_per_point = np.sum(points_in_cells, axis=0) + + n_cells_per_point[n_cells_per_point == 0] = 1 + barycentric_coords /= n_cells_per_point[:, np.newaxis] + + return barycentric_coords + + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: + + points_values_per_cell = self._cell_points_values(eval_points) + + cell_points_values = np.swapaxes(points_values_per_cell, -2, -1) + cell_points_values = cell_points_values.reshape(-1, len(eval_points)) + indexes = self.cells.ravel() + + eval_matrix = np.zeros((self.n_basis, len(eval_points))) + np.add.at(eval_matrix, indexes, cell_points_values) + + return eval_matrix diff --git a/skfda/representation/basis/_fourier.py b/skfda/representation/basis/_fourier.py index a6d89623e..f1f5e619d 100644 --- a/skfda/representation/basis/_fourier.py +++ b/skfda/representation/basis/_fourier.py @@ -1,8 +1,13 @@ +from typing import Any, Optional, Tuple, TypeVar + import numpy as np -from ..._utils import _domain_range +from ..._utils import _to_domain_range +from .._typing import DomainRangeLike from ._basis import Basis +T = TypeVar("T", bound='Fourier') + class Fourier(Basis): r"""Fourier basis. @@ -24,11 +29,11 @@ class Fourier(Basis): Actually this basis functions are not orthogonal but not orthonormal. To achieve this they are divided by its norm: :math:`\sqrt{\frac{T}{2}}`. - Attributes: - domain_range (tuple): A tuple of length 2 containing the initial and + Parameters: + domain_range: A tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - n_basis (int): Number of functions in the basis. - period (int or float): Period (:math:`T`). + n_basis: Number of functions in the basis. + period: Period (:math:`T`). Examples: Constructs specifying number of basis, definition interval and period. @@ -67,23 +72,28 @@ class Fourier(Basis): """ - def __init__(self, domain_range=None, n_basis=3, period=None): - """Construct a Fourier object. + def __init__( + self, + domain_range: Optional[DomainRangeLike] = None, + n_basis: int = 3, + period: Optional[float] = None, + ) -> None: + """ + Construct a Fourier object. It forces the object to have an odd number of basis. If n_basis is even, it is incremented by one. Args: - domain_range (tuple): Tuple defining the domain over which the - function is defined. - n_basis (int): Number of basis functions. - period (int or float): Period of the trigonometric functions that + domain_range: Tuple defining the domain over which the + function is defined. + n_basis: Number of basis functions. + period: Period of the trigonometric functions that define the basis. """ - if domain_range is not None: - domain_range = _domain_range(domain_range) + domain_range = _to_domain_range(domain_range) if len(domain_range) != 1: raise ValueError("Domain range should be unidimensional.") @@ -96,13 +106,13 @@ def __init__(self, domain_range=None, n_basis=3, period=None): super().__init__(domain_range=domain_range, n_basis=n_basis) @property - def period(self): + def period(self) -> float: if self._period is None: return self.domain_range[0][1] - self.domain_range[0][0] - else: - return self._period - def _evaluate(self, eval_points): + return self._period + + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: # Input is scalar eval_points = eval_points[..., 0] @@ -120,7 +130,7 @@ def _evaluate(self, eval_points): res = np.einsum('ij,k->ijk', phase_coefs, eval_points) # Apply odd and even functions - for i in [0, 1]: + for i in (0, 1): functions[i](res[:, i, :], out=res[:, i, :]) res = res.reshape(-1, len(eval_points)) @@ -128,21 +138,26 @@ def _evaluate(self, eval_points): constant_basis = np.full( shape=(1, len(eval_points)), - fill_value=1 / (np.sqrt(2) * normalization_denominator)) - - res = np.concatenate((constant_basis, res)) + fill_value=1 / (np.sqrt(2) * normalization_denominator), + ) - return res + return np.concatenate((constant_basis, res)) - def _derivative_basis_and_coefs(self, coefs, order=1): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: omega = 2 * np.pi / self.period deriv_factor = (np.arange(1, (self.n_basis + 1) / 2) * omega) ** order deriv_coefs = np.zeros(coefs.shape) - cos_sign, sin_sign = ((-1) ** int((order + 1) / 2), - (-1) ** int(order / 2)) + cos_sign, sin_sign = ( + (-1) ** int((order + 1) / 2), + (-1) ** int(order / 2), + ) if order % 2 == 0: deriv_coefs[:, 1::2] = sin_sign * coefs[:, 1::2] * deriv_factor @@ -154,26 +169,20 @@ def _derivative_basis_and_coefs(self, coefs, order=1): # normalise return self.copy(), deriv_coefs - def _gram_matrix(self): + def _gram_matrix(self) -> np.ndarray: # Orthogonal in this case if self.period == (self.domain_range[0][1] - self.domain_range[0][0]): return np.identity(self.n_basis) - else: - return super()._gram_matrix() - - def rescale(self, domain_range=None, *, rescale_period=False): - r"""Return a copy of the basis with a new domain range, with the - corresponding values rescaled to the new bounds. - - Args: - domain_range (tuple, optional): Definition of the interval - where the basis defines a space. Defaults uses the same as - the original basis. - rescale_period (bool, optional): If true the period will be - rescaled using the ratio between the lengths of the new - and old interval. Defaults to False. - """ + + return super()._gram_matrix() + + def rescale( # noqa: D102 + self: T, + domain_range: Optional[DomainRangeLike] = None, + *, + rescale_period: bool = False, + ) -> T: rescale_basis = super().rescale(domain_range) @@ -182,26 +191,35 @@ def rescale(self, domain_range=None, *, rescale_period=False): domain_rescaled = rescale_basis.domain_range[0] domain = self.domain_range[0] - rescale_basis._period = ( - self.period * - (domain_rescaled[1] - domain_rescaled[0]) / - (domain[1] - domain[0])) + rescale_basis._period = ( # noqa: WPS437 + self.period + * (domain_rescaled[1] - domain_rescaled[0]) + / (domain[1] - domain[0]) + ) return rescale_basis - def _to_R(self): + def _to_R(self) -> str: # noqa: N802 drange = self.domain_range[0] - return ("create.fourier.basis(rangeval = c(" + str(drange[0]) + "," + - str(drange[1]) + "), nbasis = " + str(self.n_basis) + - ", period = " + str(self.period) + ")") - - def __repr__(self): + rangeval = f"c({drange[0]}, {drange[1]})" + return ( + f"create.fourier.basis(" + f"rangeval = {rangeval}, " + f"nbasis = {self.n_basis}, " + f"period = {self.period})" + ) + + def __repr__(self) -> str: """Representation of a Fourier basis.""" - return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"n_basis={self.n_basis}, period={self.period})") - - def __eq__(self, other): + return ( + f"{self.__class__.__name__}(" + f"domain_range={self.domain_range}, " + f"n_basis={self.n_basis}, " + f"period={self.period})" + ) + + def __eq__(self, other: Any) -> bool: return super().__eq__(other) and self.period == other.period - def __hash__(self): + def __hash__(self) -> int: return hash((super().__hash__(), self.period)) diff --git a/skfda/representation/basis/_monomial.py b/skfda/representation/basis/_monomial.py index 273b31c97..811fc4d36 100644 --- a/skfda/representation/basis/_monomial.py +++ b/skfda/representation/basis/_monomial.py @@ -1,8 +1,12 @@ +from typing import Tuple, TypeVar + import numpy as np import scipy.linalg from ._basis import Basis +T = TypeVar("T", bound='Monomial') + class Monomial(Basis): """Monomial basis. @@ -13,9 +17,9 @@ class Monomial(Basis): 1, t, t^2, t^3... Attributes: - domain_range (tuple): a tuple of length 2 containing the initial and + domain_range: a tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - n_basis (int): number of functions in the basis. + n_basis: number of functions in the basis. Examples: Defines a monomial base over the interval :math:`[0, 5]` consisting @@ -63,7 +67,7 @@ class Monomial(Basis): [ 2.]]]) """ - def _evaluate(self, eval_points): + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: # Input is scalar eval_points = eval_points[..., 0] @@ -73,31 +77,37 @@ def _evaluate(self, eval_points): return raised.T - def _derivative_basis_and_coefs(self, coefs, order=1): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: if order >= self.n_basis: return ( - Monomial(domain_range=self.domain_range, n_basis=1), + type(self)(domain_range=self.domain_range, n_basis=1), np.zeros((len(coefs), 1)), ) return ( - Monomial( + type(self)( domain_range=self.domain_range, n_basis=self.n_basis - order, ), np.array([np.polyder(x[::-1], order)[::-1] for x in coefs]), ) - def _gram_matrix(self): + def _gram_matrix(self) -> np.ndarray: integral_coefs = np.polyint(np.ones(2 * self.n_basis - 1)) # We obtain the powers of both extremes in the domain range power_domain_limits = np.vander( - self.domain_range[0], 2 * self.n_basis) + self.domain_range[0], 2 * self.n_basis, + ) # Subtract the powers (Barrow's rule) power_domain_limits_diff = ( - power_domain_limits[1] - power_domain_limits[0]) + power_domain_limits[1] - power_domain_limits[0] + ) # Multiply the constants that appear in the integration evaluated_points = integral_coefs * power_domain_limits_diff @@ -109,9 +119,14 @@ def _gram_matrix(self): # Build the matrix return scipy.linalg.hankel( ordered_evaluated_points[:self.n_basis], - ordered_evaluated_points[self.n_basis - 1:]) + ordered_evaluated_points[self.n_basis - 1:], + ) - def _to_R(self): + def _to_R(self) -> str: # noqa: N802 drange = self.domain_range[0] - return "create.monomial.basis(rangeval = c(" + str(drange[0]) + "," +\ - str(drange[1]) + "), nbasis = " + str(self.n_basis) + ")" + rangeval = f"c({drange[0]}, {drange[1]})" + return ( + f"create.monomial.basis(" + f"rangeval = {rangeval}, " + f"nbasis = {self.n_basis})" + ) diff --git a/skfda/representation/basis/_tensor_basis.py b/skfda/representation/basis/_tensor_basis.py index ae625a1a9..8d6a07191 100644 --- a/skfda/representation/basis/_tensor_basis.py +++ b/skfda/representation/basis/_tensor_basis.py @@ -80,10 +80,6 @@ def __init__(self, basis_list: Iterable[Basis]): def basis_list(self) -> Tuple[Basis, ...]: return self._basis_list - @property - def dim_domain(self) -> int: - return len(self.basis_list) - def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: matrix = np.zeros((self.n_basis, len(eval_points), self.dim_codomain)) diff --git a/skfda/representation/basis/_vector_basis.py b/skfda/representation/basis/_vector_basis.py index 29b7080c0..2f6e69a72 100644 --- a/skfda/representation/basis/_vector_basis.py +++ b/skfda/representation/basis/_vector_basis.py @@ -1,9 +1,18 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterable, Tuple, TypeVar, Union + import numpy as np import scipy.linalg from ..._utils import _same_domain from ._basis import Basis +if TYPE_CHECKING: + from .. import FDataBasis + +T = TypeVar("T", bound='VectorValued') + class VectorValued(Basis): r"""Vector-valued basis. @@ -57,101 +66,116 @@ class VectorValued(Basis): """ - def __init__(self, basis_list): + def __init__(self, basis_list: Iterable[Basis]) -> None: basis_list = tuple(basis_list) if not all(b.dim_codomain == 1 for b in basis_list): - raise ValueError("The basis functions must be " - "scalar valued") - - if any(b.dim_domain != basis_list[0].dim_domain or - not _same_domain(b, basis_list[0]) - for b in basis_list): - raise ValueError("The basis must all have the same domain " - "dimension an range") + raise ValueError( + "The basis functions must be scalar valued", + ) + + if any( + b.dim_domain != basis_list[0].dim_domain + or not _same_domain(b, basis_list[0]) + for b in basis_list + ): + raise ValueError( + "The basis must all have the same domain " + "dimension and range", + ) self._basis_list = basis_list super().__init__( domain_range=basis_list[0].domain_range, - n_basis=sum(b.n_basis for b in basis_list)) + n_basis=sum(b.n_basis for b in basis_list), + ) @property - def basis_list(self): + def basis_list(self) -> Tuple[Basis, ...]: return self._basis_list @property - def dim_domain(self): + def dim_domain(self) -> int: return self.basis_list[0].dim_domain @property - def dim_codomain(self): + def dim_codomain(self) -> int: return len(self.basis_list) - def _evaluate(self, eval_points): + def _evaluate(self, eval_points: np.ndarray) -> np.ndarray: matrix = np.zeros((self.n_basis, len(eval_points), self.dim_codomain)) - n_basis_evaluated = 0 + n_basis_eval = 0 - basis_evaluations = [b._evaluate(eval_points) for b in self.basis_list] + basis_evaluations = [b.evaluate(eval_points) for b in self.basis_list] for i, ev in enumerate(basis_evaluations): - matrix[n_basis_evaluated:n_basis_evaluated + len(ev), :, i] = ev - n_basis_evaluated += len(ev) + matrix[n_basis_eval:n_basis_eval + len(ev), :, i] = ev[..., 0] + n_basis_eval += len(ev) return matrix - def _derivative_basis_and_coefs(self, coefs, order=1): + def _derivative_basis_and_coefs( + self: T, + coefs: np.ndarray, + order: int = 1, + ) -> Tuple[T, np.ndarray]: n_basis_list = [b.n_basis for b in self.basis_list] indexes = np.cumsum(n_basis_list) coefs_per_basis = np.hsplit(coefs, indexes[:-1]) - basis_and_coefs = [b._derivative_basis_and_coefs( - c, order=order) for b, c in zip(self.basis_list, coefs_per_basis)] + basis_and_coefs = [ + b._derivative_basis_and_coefs(c, order=order) # noqa: WPS437 + for b, c in zip(self.basis_list, coefs_per_basis) + ] new_basis_list, new_coefs_list = zip(*basis_and_coefs) - new_basis = VectorValued(new_basis_list) + new_basis = type(self)(new_basis_list) new_coefs = np.hstack(new_coefs_list) return new_basis, new_coefs - def _gram_matrix(self): + def _gram_matrix(self) -> np.ndarray: gram_matrices = [b.gram_matrix() for b in self.basis_list] return scipy.linalg.block_diag(*gram_matrices) - def _coordinate_nonfull(self, fdatabasis, key): - - r_key = key - if isinstance(r_key, int): - r_key = range(r_key, r_key + 1) - s_key = slice(r_key.start, r_key.stop, r_key.step) + def _coordinate_nonfull( + self, + coefs: np.ndarray, + key: Union[int, slice], + ) -> Tuple[Basis, np.ndarray]: - coef_indexes = np.concatenate([ - np.ones(b.n_basis, dtype=np.bool_) if i in r_key - else np.zeros(b.n_basis, dtype=np.bool_) - for i, b in enumerate(self.basis_list)]) + basis_sizes = [b.n_basis for b in self.basis_list] + basis_indexes = np.cumsum(basis_sizes) + coef_splits = np.split(coefs, basis_indexes[:-1], axis=1) - new_basis_list = self.basis_list[key] + new_basis = self.basis_list[key] + if not isinstance(new_basis, Basis): + new_basis = VectorValued(new_basis) - basis = (new_basis_list if isinstance(new_basis_list, Basis) - else VectorValued(new_basis_list)) + new_coefs = coef_splits[key] + if not isinstance(new_coefs, np.ndarray): + new_coefs = np.concatenate(coef_splits[key], axis=1) - coefs = fdatabasis.coefficients[:, coef_indexes] - - coordinate_names = np.array(fdatabasis.coordinate_names)[s_key] + return new_basis, new_coefs - return fdatabasis.copy(basis=basis, coefficients=coefs, - coordinate_names=coordinate_names) + def __repr__(self) -> str: + """Representation of a Basis object.""" + return ( + f"{self.__class__.__name__}(" + f"basis_list={self.basis_list})" + ) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return super().__eq__(other) and self.basis_list == other.basis_list - def __hash__(self): + def __hash__(self) -> int: return hash((super().__hash__(), self.basis_list)) diff --git a/skfda/representation/evaluator.py b/skfda/representation/evaluator.py index 24ad1f1bc..eb1fe9d94 100644 --- a/skfda/representation/evaluator.py +++ b/skfda/representation/evaluator.py @@ -5,11 +5,18 @@ evaluation of FDataGrids. """ +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Iterable, Union, overload import numpy as np -from typing_extensions import Protocol +from typing_extensions import Literal, Protocol + +from ._typing import ArrayLike + +if TYPE_CHECKING: + from . import FData class Evaluator(ABC): @@ -24,16 +31,48 @@ class Evaluator(ABC): The evaluator is called internally by :func:`evaluate`. - Should implement the methods :func:`evaluate` and - :func:`evaluate_composed`. - """ @abstractmethod - def evaluate( + def _evaluate( + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: + """ + Evaluate the samples at evaluation points. + + Must be overriden in subclasses. + + """ + pass + + @overload + def __call__( + self, + fdata: FData, + eval_points: ArrayLike, + *, + aligned: Literal[True] = True, + ) -> np.ndarray: + pass + + @overload + def __call__( + self, + fdata: FData, + eval_points: Iterable[ArrayLike], + *, + aligned: Literal[False], + ) -> np.ndarray: + pass + + def __call__( self, - fdata: Callable[[np.ndarray], np.ndarray], - eval_points: np.ndarray, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], *, aligned: bool = True, ) -> np.ndarray: @@ -61,7 +100,11 @@ def evaluate( j-th evaluation point. """ - pass + return self._evaluate( + fdata=fdata, + eval_points=eval_points, + aligned=aligned, + ) def __repr__(self) -> str: return f"{type(self)}()" @@ -76,8 +119,8 @@ class EvaluateFunction(Protocol): def __call__( self, - fdata: Callable[[np.ndarray], np.ndarray], - eval_points: np.ndarray, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], *, aligned: bool = True, ) -> np.ndarray: @@ -90,7 +133,7 @@ def __call__( Args: fdata: Object to evaluate. - eval_points (numpy.ndarray): Numpy array with shape + eval_points: Numpy array with shape ``(number_eval_points, dim_domain)`` with the evaluation points. aligned: Whether the input points are @@ -98,11 +141,11 @@ def __call__( passed. Returns: - (numpy.darray): Numpy 3d array with shape - ``(n_samples, number_eval_points, dim_codomain)`` with the - result of the evaluation. The entry ``(i,j,k)`` will contain - the value k-th image dimension of the i-th sample, at the - j-th evaluation point. + Numpy 3d array with shape + ``(n_samples, number_eval_points, dim_codomain)`` with the + result of the evaluation. The entry ``(i,j,k)`` will contain + the value k-th image dimension of the i-th sample, at the + j-th evaluation point. """ pass @@ -120,11 +163,12 @@ class GenericEvaluator(Evaluator): def __init__(self, evaluate_function: EvaluateFunction) -> None: self.evaluate_function = evaluate_function - def evaluate( # noqa: D102 + def _evaluate( # noqa: D102 self, - fdata: Callable[[np.ndarray], np.ndarray], - eval_points: np.ndarray, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], *, aligned: bool = True, ) -> np.ndarray: + return self.evaluate_function(fdata, eval_points, aligned=aligned) diff --git a/skfda/representation/extrapolation.py b/skfda/representation/extrapolation.py index e9e761546..6217014a1 100644 --- a/skfda/representation/extrapolation.py +++ b/skfda/representation/extrapolation.py @@ -3,19 +3,38 @@ Defines methods to evaluate points outside the :term:`domain` range. """ - -from typing import Optional, Union +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, + Any, + Iterable, + NoReturn, + Optional, + Union, + cast, + overload, +) import numpy as np +from typing_extensions import Literal +from ._typing import ArrayLike from .evaluator import Evaluator +if TYPE_CHECKING: + from . import FData + +ExtrapolationLike = Union[ + Evaluator, + Literal["bounds", "exception", "nan", "none", "periodic", "zeros"], +] + class PeriodicExtrapolation(Evaluator): - """Extends the :term:`domain` range periodically. + """Extend the :term:`domain` range periodically. Examples: - >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.extrapolation import ( ... PeriodicExtrapolation) @@ -44,7 +63,13 @@ class PeriodicExtrapolation(Evaluator): [-1.086]]]) """ - def evaluate(self, fdata, eval_points, *, aligned=True): + def _evaluate( # noqa: D102 + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: domain_range = np.asarray(fdata.domain_range) @@ -53,16 +78,13 @@ def evaluate(self, fdata, eval_points, *, aligned=True): eval_points %= domain_range[:, 1] - domain_range[:, 0] eval_points += domain_range[:, 0] - res = fdata(eval_points, aligned=aligned) - - return res + return fdata(eval_points, aligned=aligned) # type: ignore class BoundaryExtrapolation(Evaluator): - """Extends the :term:`domain` range using the boundary values. + """Extend the :term:`domain` range using the boundary values. Examples: - >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.extrapolation import ( ... BoundaryExtrapolation) @@ -91,25 +113,42 @@ class BoundaryExtrapolation(Evaluator): [ 1.125]]]) """ - def evaluate(self, fdata, eval_points, *, aligned=True): + def _evaluate( # noqa: D102 + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: domain_range = fdata.domain_range - for i in range(fdata.dim_domain): - a, b = domain_range[i] - eval_points[eval_points[..., i] < a, i] = a - eval_points[eval_points[..., i] > b, i] = b + if aligned: + eval_points = np.asarray(eval_points) - res = fdata(eval_points, aligned=aligned) + for i in range(fdata.dim_domain): + a, b = domain_range[i] + eval_points[eval_points[..., i] < a, i] = a + eval_points[eval_points[..., i] > b, i] = b + else: + eval_points = cast(Iterable[ArrayLike], eval_points) - return res + for points_per_sample in eval_points: + + points_per_sample = np.asarray(points_per_sample) + + for i in range(fdata.dim_domain): + a, b = domain_range[i] + points_per_sample[points_per_sample[..., i] < a, i] = a + points_per_sample[points_per_sample[..., i] > b, i] = b + + return fdata(eval_points, aligned=aligned) # type: ignore class ExceptionExtrapolation(Evaluator): - """Raise and exception. + """Raise an exception. Examples: - >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.extrapolation import ( ... ExceptionExtrapolation) @@ -122,7 +161,7 @@ class ExceptionExtrapolation(Evaluator): ... fd([-.5, 0, 1.5]).round(3) ... except ValueError as e: ... print(e) - Attempt to evaluate 2 points outside the domain range. + Attempt to evaluate points outside the domain range. This extrapolator is equivalent to the string `"exception"`. @@ -131,16 +170,21 @@ class ExceptionExtrapolation(Evaluator): ... fd([-.5, 0, 1.5]).round(3) ... except ValueError as e: ... print(e) - Attempt to evaluate 2 points outside the domain range. + Attempt to evaluate points outside the domain range. """ - def evaluate(self, fdata, eval_points, *, aligned=True): - - n_points = eval_points.shape[-2] + def _evaluate( # noqa: D102 + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> NoReturn: - raise ValueError(f"Attempt to evaluate {n_points} points outside the " - f"domain range.") + raise ValueError( + "Attempt to evaluate points outside the domain range.", + ) class FillExtrapolation(Evaluator): @@ -148,7 +192,6 @@ class FillExtrapolation(Evaluator): Values outside the :term:`domain` range will be filled with a fixed value. Examples: - >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.extrapolation import FillExtrapolation >>> fd = make_sinusoidal_process(n_samples=2, random_state=0) @@ -177,34 +220,72 @@ class FillExtrapolation(Evaluator): [ nan]]]) """ - def __init__(self, fill_value): + def __init__(self, fill_value: float) -> None: self.fill_value = fill_value - def _fill(self, fdata, eval_points): - shape = (fdata.n_samples, eval_points.shape[-2], - fdata.dim_codomain) + def _fill(self, fdata: FData, eval_points: ArrayLike) -> np.ndarray: + eval_points = np.asarray(eval_points) + + shape = ( + fdata.n_samples, + eval_points.shape[-2], + fdata.dim_codomain, + ) return np.full(shape, self.fill_value) - def evaluate(self, fdata, eval_points, *, aligned=True): + def _evaluate( # noqa: D102 + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: + from .._utils import _to_array_maybe_ragged + + if aligned: + eval_points = cast(ArrayLike, eval_points) + return self._fill(fdata, eval_points) - return self._fill(fdata, eval_points) + eval_points = cast(Iterable[ArrayLike], eval_points) - def __repr__(self): - """repr method of FillExtrapolation""" - return (f"{type(self).__name__}(" - f"fill_value={self.fill_value})") + res_list = [self._fill(fdata, p) for p in eval_points] - def __eq__(self, other): - """Equality operator bethween FillExtrapolation instances.""" - return (super().__eq__(other) and + return _to_array_maybe_ragged(res_list) + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(" + f"fill_value={self.fill_value})" + ) + + def __eq__(self, other: Any) -> bool: + return ( + super().__eq__(other) + and ( self.fill_value == other.fill_value # NaNs compare unequal. Should we distinguish between # different NaN types and payloads? - or np.isnan(self.fill_value) and np.isnan(other.fill_value)) + or (np.isnan(self.fill_value) and np.isnan(other.fill_value)) + ) + ) + + +@overload +def _parse_extrapolation( + extrapolation: None, +) -> None: + pass + + +@overload +def _parse_extrapolation( + extrapolation: ExtrapolationLike, +) -> Evaluator: + pass def _parse_extrapolation( - extrapolation: Optional[Union[str, Evaluator]], + extrapolation: Optional[ExtrapolationLike], ) -> Optional[Evaluator]: """Parse the argument `extrapolation` of `FData`. @@ -213,7 +294,6 @@ def _parse_extrapolation( Args: extrapolation (:class:´Extrapolator´, str or Callable): Argument extrapolation to be parsed. - fdata (:class:´FData´): Object with the default extrapolation. Returns: (:class:´Extrapolator´ or Callable): Extrapolation method. @@ -225,14 +305,15 @@ def _parse_extrapolation( elif isinstance(extrapolation, str): return extrapolation_methods[extrapolation.lower()] - else: - return extrapolation + return extrapolation #: Dictionary with the extrapolation methods. -extrapolation_methods = {"bounds": BoundaryExtrapolation(), - "exception": ExceptionExtrapolation(), - "nan": FillExtrapolation(np.nan), - "none": None, - "periodic": PeriodicExtrapolation(), - "zeros": FillExtrapolation(0)} +extrapolation_methods = { + "bounds": BoundaryExtrapolation(), + "exception": ExceptionExtrapolation(), + "nan": FillExtrapolation(np.nan), + "none": None, + "periodic": PeriodicExtrapolation(), + "zeros": FillExtrapolation(0), +} diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index b75caf0b2..9f3fb1bef 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -5,57 +5,82 @@ list of discretisation points. """ +from __future__ import annotations import copy import numbers import warnings -from typing import Any +from typing import ( + TYPE_CHECKING, + Any, + Iterable, + Optional, + Sequence, + Type, + TypeVar, + Union, + cast, +) import findiff import numpy as np import pandas.api.extensions +from matplotlib.figure import Figure + import scipy.stats.mstats from .._utils import ( _check_array_key, - _domain_range, _int_to_real, - _tuple_of_arrays, + _to_domain_range, + _to_grid_points, constants, ) -from . import basis as fdbasis from ._functional_data import FData +from ._typing import ( + ArrayLike, + DomainRange, + DomainRangeLike, + GridPoints, + GridPointsLike, + LabelTupleLike, +) +from .basis import Basis +from .evaluator import Evaluator +from .extrapolation import ExtrapolationLike from .interpolation import SplineInterpolation -__author__ = "Miguel Carbajo Berrocal" -__email__ = "miguel.carbajo@estudiante.uam.es" +if TYPE_CHECKING: + from .. import FDataBasis +T = TypeVar("T", bound='FDataGrid') -class FDataGrid(FData): + +class FDataGrid(FData): # noqa: WPS214 r"""Represent discretised functional data. Class for representing functional data as a set of curves discretised in a grid of points. Attributes: - data_matrix (numpy.ndarray): a matrix where each entry of the first + data_matrix: a matrix where each entry of the first axis contains the values of a functional datum evaluated at the points of discretisation. - grid_points (numpy.ndarray): 2 dimension matrix where each row + grid_points: 2 dimension matrix where each row contains the points of dicretisation for each axis of data_matrix. - domain_range (numpy.ndarray): 2 dimension matrix where each row + domain_range: 2 dimension matrix where each row contains the bounds of the interval in which the functional data is considered to exist for each one of the axies. - dataset_name (str): name of the dataset. - argument_names (tuple): tuple containing the names of the different + dataset_name: name of the dataset. + argument_names: tuple containing the names of the different arguments. - coordinate_names (tuple): tuple containing the names of the different + coordinate_names: tuple containing the names of the different coordinate functions. - extrapolation (str or Extrapolation): defines the default type of + extrapolation: defines the default type of extrapolation. By default None, which does not apply any type of extrapolation. See `Extrapolation` for detailled information of the types of extrapolation. - interpolation (GridInterpolation): Defines the type of interpolation + interpolation: Defines the type of interpolation applied in `evaluate`. Examples: @@ -108,160 +133,156 @@ class FDataGrid(FData): """ - class _CoordinateIterator: - """Internal class to iterate through the image coordinates.""" - - def __init__(self, fdatagrid): - """Create an iterator through the image coordinates.""" - self._fdatagrid = fdatagrid - - def __iter__(self): - """Return an iterator through the image coordinates.""" - - for i in range(len(self)): - yield self[i] - - def __getitem__(self, key): - """Get a specific coordinate.""" - - s_key = key - if isinstance(s_key, int): - s_key = slice(s_key, s_key + 1) - - coordinate_names = np.array( - self._fdatagrid.coordinate_names)[s_key] - - return self._fdatagrid.copy( - data_matrix=self._fdatagrid.data_matrix[..., key], - coordinate_names=coordinate_names) - - def __len__(self): - """Return the number of coordinates.""" - return self._fdatagrid.dim_codomain - - def __init__(self, data_matrix, grid_points=None, - *, - sample_points=None, - domain_range=None, - dataset_label=None, - dataset_name=None, - argument_names=None, - coordinate_names=None, - sample_names=None, - axes_labels=None, extrapolation=None, - interpolation=None): - """Construct a FDataGrid object. - - Args: - data_matrix (array_like): a matrix where each row contains the - values of a functional datum evaluated at the - points of discretisation. - grid_points (array_like, optional): an array containing the - points of discretisation where values have been recorded or a - list of lists with each of the list containing the points of - dicretisation for each axis. - domain_range (tuple or list of tuples, optional): contains the - edges of the interval in which the functional data is - considered to exist (if the argument has 2 dimensions each - row is interpreted as the limits of one of the dimension of - the domain). - dataset_label (str, optional): name of the dataset. - axes_labels (list, optional): list containing the labels of the - different axes. The length of the list must be equal to the sum - of the number of dimensions of the domain plus the number of - dimensions of the image. - """ + def __init__( # noqa: WPS211 + self, + data_matrix: ArrayLike, + grid_points: Optional[GridPointsLike] = None, + *, + sample_points: Optional[GridPointsLike] = None, + domain_range: Optional[DomainRangeLike] = None, + dataset_label: Optional[str] = None, + dataset_name: Optional[str] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, + axes_labels: Optional[LabelTupleLike] = None, + extrapolation: Optional[ExtrapolationLike] = None, + interpolation: Optional[Evaluator] = None, + ): + """Construct a FDataGrid object.""" if sample_points is not None: - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) grid_points = sample_points self.data_matrix = _int_to_real(np.atleast_2d(data_matrix)) if grid_points is None: - self.grid_points = _tuple_of_arrays( - [np.linspace(0., 1., self.data_matrix.shape[i]) for i in - range(1, self.data_matrix.ndim)]) + self.grid_points = _to_grid_points([ + np.linspace(0, 1, self.data_matrix.shape[i]) + for i in range(1, self.data_matrix.ndim) + ]) else: # Check that the dimension of the data matches the grid_points # list - self.grid_points = _tuple_of_arrays(grid_points) + self.grid_points = _to_grid_points(grid_points) data_shape = self.data_matrix.shape[1: 1 + self.dim_domain] grid_points_shape = [len(i) for i in self.grid_points] if not np.array_equal(data_shape, grid_points_shape): - raise ValueError("Incorrect dimension in data_matrix and " - "grid_points. Data has shape {} and grid " - "points have shape {}" - .format(data_shape, grid_points_shape)) + raise ValueError( + f"Incorrect dimension in data_matrix and " + f"grid_points. Data has shape {data_shape} and grid " + f"points have shape {grid_points_shape}", + ) - self._sample_range = np.array( - [(s[0], s[-1]) for s in self.grid_points]) + self._sample_range = tuple( + (s[0], s[-1]) for s in self.grid_points + ) if domain_range is None: domain_range = self.sample_range # Default value for domain_range is a list of tuples with # the first and last element of each list of the grid_points. - self._domain_range = _domain_range(domain_range) + self._domain_range = _to_domain_range(domain_range) if len(self._domain_range) != self.dim_domain: raise ValueError("Incorrect shape of domain_range.") - for i in range(self.dim_domain): - if (self._domain_range[i][0] > self.grid_points[i][0] - or self._domain_range[i][-1] < self.grid_points[i] - [-1]): - raise ValueError("Sample points must be within the domain " - "range.") + for domain_range, grid_points in zip( + self._domain_range, + self.grid_points, + ): + if ( + domain_range[0] > grid_points[0] + or domain_range[-1] < grid_points[-1] + ): + raise ValueError( + "Grid points must be within the domain range.", + ) # Adjust the data matrix if the dimension of the image is one if self.data_matrix.ndim == 1 + self.dim_domain: self.data_matrix = self.data_matrix[..., np.newaxis] - self.interpolation = interpolation - - super().__init__(extrapolation=extrapolation, - dataset_label=dataset_label, - dataset_name=dataset_name, - axes_labels=axes_labels, - argument_names=argument_names, - coordinate_names=coordinate_names, - sample_names=sample_names) - - def round(self, decimals=0): + self.interpolation = interpolation # type: ignore + + super().__init__( + extrapolation=extrapolation, + dataset_label=dataset_label, + dataset_name=dataset_name, + axes_labels=axes_labels, + argument_names=argument_names, + coordinate_names=coordinate_names, + sample_names=sample_names, + ) + + def round( # noqa: WPS125 + self, + decimals: int = 0, + out: Optional[FDataGrid] = None, + ) -> FDataGrid: """Evenly round to the given number of decimals. + .. deprecated:: 0.6 + Use :func:`numpy.round` function instead. + Args: - decimals (int, optional): Number of decimal places to round to. + decimals: Number of decimal places to round to. If decimals is negative, it specifies the number of positions to the left of the decimal point. Defaults to 0. + out: FDataGrid where to place the result, if any. Returns: - :obj:FDataGrid: Returns a FDataGrid object where all elements - in its data_matrix are rounded .The real and - imaginary parts of complex numbers are rounded separately. + Returns a FDataGrid object where all elements + in its data_matrix are rounded. """ - return self.copy(data_matrix=self.data_matrix.round(decimals)) + out_matrix = None if out is None else out.data_matrix + + if ( + out is not None + and ( + self.domain_range != out.domain_range + or not all( + np.array_equal(a, b) + for a, b in zip(self.grid_points, out.grid_points) + ) + or self.data_matrix.shape != out.data_matrix.shape + ) + ): + raise ValueError("out parameter is not valid") + + data_matrix = np.round( + self.data_matrix, + decimals=decimals, + out=out_matrix, + ) + + return self.copy(data_matrix=data_matrix) if out is None else out @property - def sample_points(self): - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + def sample_points(self) -> GridPoints: + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) return self.grid_points @property - def dim_domain(self): + def dim_domain(self) -> int: return len(self.grid_points) @property - def dim_codomain(self): + def dim_codomain(self) -> int: try: # The dimension of the image is the length of the array that can # be extracted from the data_matrix using all the dimensions of @@ -272,7 +293,7 @@ def dim_codomain(self): return 1 @property - def coordinates(self): + def coordinates(self: T) -> _CoordinateIterator[T]: r"""Returns an object to access to the image coordinates. If the functional object contains multivariate samples @@ -326,76 +347,81 @@ def coordinates(self): """ - return FDataGrid._CoordinateIterator(self) + return _CoordinateIterator(self) @property - def n_samples(self): - """Return number of rows of the data_matrix. Also the number of samples. + def n_samples(self) -> int: + """ + Return the number of samples. + + This is also the number of rows of the data_matrix. Returns: - int: Number of samples of the FDataGrid object. Also the number of - rows of the data_matrix. + Number of samples of the FDataGrid object. """ return self.data_matrix.shape[0] @property - def ncol(self): - """Return number of columns of the data_matrix. - - Also the number of points of discretisation. - - Returns: - int: Number of columns of the data_matrix. - + def sample_range(self) -> DomainRange: """ - return self.data_matrix.shape[1] + Return the sample range of the function. - @property - def sample_range(self): - """Return the edges of the interval in which the functional data is - considered to exist by the sample points. + This contains the minimum and maximum values of the grid points in + each dimension. - Do not have to be equal to the domain_range. + It does not have to be equal to the `domain_range`. """ return self._sample_range @property - def domain_range(self): - """Return the edges of the interval in which the functional data is - considered to exist by the sample points. + def domain_range(self) -> DomainRange: + """ + Return the :term:`domain range` of the function. + + It does not have to be equal to the `sample_range`. - Do not have to be equal to the sample_range. """ return self._domain_range @property - def interpolation(self): - """Defines the type of interpolation applied in `evaluate`.""" + def interpolation(self) -> Evaluator: + """Define the type of interpolation applied in `evaluate`.""" return self._interpolation @interpolation.setter - def interpolation(self, new_interpolation): - """Sets the interpolation of the FDataGrid.""" + def interpolation(self, new_interpolation: Optional[Evaluator]) -> None: + if new_interpolation is None: new_interpolation = SplineInterpolation() self._interpolation = new_interpolation - def _evaluate(self, eval_points, *, aligned=True): + def _evaluate( + self, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: - return self.interpolation.evaluate(self, eval_points, - aligned=aligned) + return self.interpolation( # type: ignore + self, + eval_points, + aligned=aligned, + ) - def derivative(self, *, order=1): - r"""Differentiate a FDataGrid object. + def derivative(self: T, *, order: int = 1) -> T: + """Differentiate a FDataGrid object. It is calculated using central finite differences when possible. In the extremes, forward and backward finite differences with accuracy 2 are used. Args: - order (int, optional): Order of the derivative. Defaults to one. + order: Order of the derivative. Defaults to one. + + Returns: + Derivative function. Examples: First order derivative @@ -431,38 +457,49 @@ def derivative(self, *, order=1): if order_list.ndim != 1 or len(order_list) != self.dim_domain: raise ValueError("The order for each partial should be specified.") - operator = findiff.FinDiff(*[(1 + i, p, o) - for i, (p, o) in enumerate( - zip(self.grid_points, order_list))]) + operator = findiff.FinDiff(*[ + (1 + i, *z) + for i, z in enumerate( + zip(self.grid_points, order_list), + ) + ]) data_matrix = operator(self.data_matrix.astype(float)) - if self.dataset_name: - dataset_name = "{} - {} derivative".format(self.dataset_name, - order) - else: - dataset_name = None - - fdatagrid = self.copy(data_matrix=data_matrix, - dataset_name=dataset_name) - - return fdatagrid + return self.copy( + data_matrix=data_matrix, + ) - def __check_same_dimensions(self, other): + def _check_same_dimensions(self: T, other: T) -> None: if self.data_matrix.shape[1:-1] != other.data_matrix.shape[1:-1]: raise ValueError("Error in columns dimensions") if not np.array_equal(self.grid_points, other.grid_points): - raise ValueError("Sample points for both objects must be equal") - - def sum(self, *, axis=None, out=None, keepdims=False, skipna=False, - min_count=0): + raise ValueError("Grid points for both objects must be equal") + + def sum( # noqa: WPS125 + self: T, + *, + axis: Optional[int] = None, + out: None = None, + keepdims: bool = False, + skipna: bool = False, + min_count: int = 0, + ) -> T: """Compute the sum of all the samples. + Args: + axis: Used for compatibility with numpy. Must be None or 0. + out: Used for compatibility with numpy. Must be None. + keepdims: Used for compatibility with numpy. Must be False. + skipna: Wether the NaNs are ignored or not. + min_count: Number of valid (non NaN) data to have in order + for the a variable to not be NaN when `skipna` is + `True`. + Returns: - FDataGrid : A FDataGrid object with just one sample representing + A FDataGrid object with just one sample representing the sum of all the samples in the original object. Examples: - >>> from skfda import FDataGrid >>> data_matrix = [[0.5, 1, 2, .5], [1.5, 1, 4, .5]] >>> FDataGrid(data_matrix).sum() @@ -476,142 +513,161 @@ def sum(self, *, axis=None, out=None, keepdims=False, skipna=False, """ super().sum(axis=axis, out=out, keepdims=keepdims, skipna=skipna) - data = (np.nansum(self.data_matrix, axis=0, keepdims=True) if skipna - else np.sum(self.data_matrix, axis=0, keepdims=True)) + data = ( + np.nansum(self.data_matrix, axis=0, keepdims=True) if skipna + else np.sum(self.data_matrix, axis=0, keepdims=True) + ) if min_count > 0: valid = ~np.isnan(self.data_matrix) n_valid = np.sum(valid, axis=0) data[n_valid < min_count] = np.NaN - return self.copy(data_matrix=data, - sample_names=(None,)) + return self.copy( + data_matrix=data, + sample_names=(None,), + ) - def var(self): + def var(self: T) -> T: """Compute the variance of a set of samples in a FDataGrid object. Returns: - FDataGrid: A FDataGrid object with just one sample representing the + A FDataGrid object with just one sample representing the variance of all the samples in the original FDataGrid object. """ - return self.copy(data_matrix=[np.var(self.data_matrix, 0)], - sample_names=("variance",)) + return self.copy( + data_matrix=np.array([np.var(self.data_matrix, 0)]), + sample_names=("variance",), + ) - def cov(self): + def cov(self: T) -> T: """Compute the covariance. Calculates the covariance matrix representing the covariance of the functional samples at the observation points. Returns: - numpy.darray: Matrix of covariances. + Covariance function. """ - - if self.dataset_name is not None: - dataset_name = self.dataset_name + ' - covariance' - else: - dataset_name = None + dataset_name = ( + f"{self.dataset_name} - covariance" + if self.dataset_name is not None else None + ) if self.dim_domain != 1 or self.dim_codomain != 1: - raise NotImplementedError("Covariance only implemented " - "for univariate functions") - - return self.copy(data_matrix=np.cov(self.data_matrix[..., 0], - rowvar=False)[np.newaxis, ...], - grid_points=[self.grid_points[0], - self.grid_points[0]], - domain_range=[self.domain_range[0], - self.domain_range[0]], - dataset_name=dataset_name, - argument_names=self.argument_names * 2, - sample_names=("covariance",)) - - def gmean(self): + raise NotImplementedError( + "Covariance only implemented " + "for univariate functions", + ) + + return self.copy( + data_matrix=np.cov( + self.data_matrix[..., 0], + rowvar=False, + )[np.newaxis, ...], + grid_points=[ + self.grid_points[0], + self.grid_points[0], + ], + domain_range=[ + self.domain_range[0], + self.domain_range[0], + ], + dataset_name=dataset_name, + argument_names=self.argument_names * 2, + sample_names=("covariance",), + ) + + def gmean(self: T) -> T: """Compute the geometric mean of all samples in the FDataGrid object. Returns: - FDataGrid: A FDataGrid object with just one sample representing + A FDataGrid object with just one sample representing the geometric mean of all the samples in the original FDataGrid object. """ - return self.copy(data_matrix=[ - scipy.stats.mstats.gmean(self.data_matrix, 0)], - sample_names=("geometric mean",)) - - def equals(self, other): - """Comparison of FDataGrid objects""" + return self.copy( + data_matrix=[ + scipy.stats.mstats.gmean(self.data_matrix, 0), + ], + sample_names=("geometric mean",), + ) + + def equals(self, other: object) -> bool: + """Comparison of FDataGrid objects.""" if not super().equals(other): return False - if not np.array_equal(self.data_matrix, other.data_matrix): - return False - - if len(self.grid_points) != len(other.grid_points): - return False + other = cast(FDataGrid, other) - for a, b in zip(self.grid_points, other.grid_points): - if not np.array_equal(a, b): - return False - - if not np.array_equal(self.domain_range, other.domain_range): + if not np.array_equal(self.data_matrix, other.data_matrix): return False - if self.interpolation != other.interpolation: + # Comparison of the domain + if ( + not np.array_equal(self.domain_range, other.domain_range) + or len(self.grid_points) != len(other.grid_points) + or not all( + np.array_equal(a, b) + for a, b in zip(self.grid_points, other.grid_points) + ) + ): return False - return True + return self.interpolation == other.interpolation - def __eq__(self, other): - """Elementwise equality of FDataGrid""" - - if not isinstance(self, type(other)) or self.dtype != other.dtype: + def __eq__(self, other: object) -> np.ndarray: # type: ignore[override] + """Elementwise equality of FDataGrid.""" + if not isinstance(other, type(self)) or self.dtype != other.dtype: if other is pandas.NA: return self.isna() if pandas.api.types.is_list_like(other) and not isinstance( other, (pandas.Series, pandas.Index, pandas.DataFrame), ): return np.concatenate([x == y for x, y in zip(self, other)]) - else: - return NotImplemented - if len(self) != len(other) and len(self) != 1 and len(other) != 1: - raise ValueError(f"Different lengths: " - f"len(self)={len(self)} and " - f"len(other)={len(other)}") - - return np.all(self.data_matrix == other.data_matrix, - axis=tuple(range(1, self.data_matrix.ndim))) + return NotImplemented - def _get_op_matrix(self, other): - if isinstance(other, numbers.Number): - return other + if len(self) != len(other) and len(self) != 1 and len(other) != 1: + raise ValueError( + f"Different lengths: " + f"len(self)={len(self)} and " + f"len(other)={len(other)}", + ) + + return np.all( + self.data_matrix == other.data_matrix, + axis=tuple(range(1, self.data_matrix.ndim)), + ) + + def _get_op_matrix( + self, + other: Union[T, np.ndarray, float], + ) -> Union[None, float, np.ndarray]: + if isinstance(other, numbers.Real): + return float(other) elif isinstance(other, np.ndarray): - if other.shape == () or other.shape == (1,): + if other.shape in {(), (1,)}: return other elif other.shape == (self.n_samples,): - other_index = ((slice(None),) + (np.newaxis,) * - (self.data_matrix.ndim - 1)) + other_index = ( + (slice(None),) + (np.newaxis,) + * (self.data_matrix.ndim - 1) + ) return other[other_index] - else: - return None elif isinstance(other, FDataGrid): - self.__check_same_dimensions(other) + self._check_same_dimensions(other) return other.data_matrix - else: - return None - - def __add__(self, other): - """Addition for FDataGrid object. - It supports other FDataGrid objects, numpy.ndarray and numbers. + return None - """ + def __add__(self: T, other: Union[T, np.ndarray, float]) -> T: data_matrix = self._get_op_matrix(other) if data_matrix is None: @@ -619,98 +675,68 @@ def __add__(self, other): return self._copy_op(other, data_matrix=self.data_matrix + data_matrix) - def __radd__(self, other): - """Addition for FDataGrid object. - - It supports other FDataGrid objects, numpy.ndarray and numbers. - - """ + def __radd__(self: T, other: Union[T, np.ndarray, float]) -> T: return self.__add__(other) - def __sub__(self, other): - """Subtraction for FDataGrid object. + def __sub__(self: T, other: Union[T, np.ndarray, float]) -> T: - It supports other FDataGrid objects, numpy.ndarray and numbers. - - """ data_matrix = self._get_op_matrix(other) if data_matrix is None: return NotImplemented return self._copy_op(other, data_matrix=self.data_matrix - data_matrix) - def __rsub__(self, other): - """Right Subtraction for FDataGrid object. + def __rsub__(self: T, other: Union[T, np.ndarray, float]) -> T: - It supports other FDataGrid objects, numpy.ndarray and numbers. - - """ data_matrix = self._get_op_matrix(other) if data_matrix is None: return NotImplemented return self.copy(data_matrix=data_matrix - self.data_matrix) - def __mul__(self, other): - """Multiplication for FDataGrid object. + def __mul__(self: T, other: Union[T, np.ndarray, float]) -> T: - It supports other FDataGrid objects, numpy.ndarray and numbers. - - """ data_matrix = self._get_op_matrix(other) if data_matrix is None: return NotImplemented return self._copy_op(other, data_matrix=self.data_matrix * data_matrix) - def __rmul__(self, other): - """Multiplication for FDataGrid object. + def __rmul__(self: T, other: Union[T, np.ndarray, float]) -> T: - It supports other FDataGrid objects, numpy.ndarray and numbers. - - """ return self.__mul__(other) - def __truediv__(self, other): - """Division for FDataGrid object. - - It supports other FDataGrid objects, numpy.ndarray and numbers. + def __truediv__(self: T, other: Union[T, np.ndarray, float]) -> T: - """ data_matrix = self._get_op_matrix(other) if data_matrix is None: return NotImplemented return self._copy_op(other, data_matrix=self.data_matrix / data_matrix) - def __rtruediv__(self, other): - """Division for FDataGrid object. - - It supports other FDataGrid objects, numpy.ndarray and numbers. + def __rtruediv__(self: T, other: Union[T, np.ndarray, float]) -> T: - """ data_matrix = self._get_op_matrix(other) if data_matrix is None: return NotImplemented return self._copy_op(other, data_matrix=data_matrix / self.data_matrix) - def concatenate(self, *others, as_coordinates=False): + def concatenate(self: T, *others: T, as_coordinates: bool = False) -> T: """Join samples from a similar FDataGrid object. Joins samples from another FDataGrid object if it has the same dimensions and sampling points. Args: - others (:obj:`FDataGrid`): Objects to be concatenated. - as_coordinates (boolean, optional): If False concatenates as + others: Objects to be concatenated. + as_coordinates: If False concatenates as new samples, else, concatenates the other functions as new components of the image. Defaults to false. Returns: - :obj:`FDataGrid`: FDataGrid object with the samples from the - original objects. + FDataGrid object with the samples from the original objects. Examples: >>> fd = FDataGrid([1,2,4,5,8], range(5)) @@ -736,69 +762,68 @@ def concatenate(self, *others, as_coordinates=False): # Checks if not as_coordinates: for other in others: - self.__check_same_dimensions(other) + self._check_same_dimensions(other) - elif not all([np.array_equal(self.grid_points, other.grid_points) - for other in others]): - raise ValueError("All the FDataGrids must be sampled in the same " - "sample points.") + elif not all( + np.array_equal(self.grid_points, other.grid_points) + for other in others + ): + raise ValueError( + "All the FDataGrids must be sampled in the same " + "grid points.", + ) - elif any([self.n_samples != other.n_samples for other in others]): + elif any(self.n_samples != other.n_samples for other in others): - raise ValueError(f"All the FDataGrids must contain the same " - f"number of samples {self.n_samples} to " - f"concatenate as a new coordinate.") + raise ValueError( + f"All the FDataGrids must contain the same " + f"number of samples {self.n_samples} to " + f"concatenate as a new coordinate.", + ) data = [self.data_matrix] + [other.data_matrix for other in others] if as_coordinates: - coordinate_names = [fd.coordinate_names for fd in [self, *others]] - - return self.copy(data_matrix=np.concatenate(data, axis=-1), - coordinate_names=sum(coordinate_names, ())) + coordinate_names = [fd.coordinate_names for fd in (self, *others)] - else: + return self.copy( + data_matrix=np.concatenate(data, axis=-1), + coordinate_names=sum(coordinate_names, ()), + ) - sample_names = [fd.sample_names for fd in [self, *others]] + sample_names = [fd.sample_names for fd in (self, *others)] - return self.copy(data_matrix=np.concatenate(data, axis=0), - sample_names=sum(sample_names, ())) + return self.copy( + data_matrix=np.concatenate(data, axis=0), + sample_names=sum(sample_names, ()), + ) - def scatter(self, *args, **kwargs): + def scatter(self, *args: Any, **kwargs: Any) -> Figure: """Scatter plot of the FDatGrid object. Args: - fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also - None, the figure is initialized. - axes (list of axis objects, optional): axis over where the graphs - are plotted. If None, see param fig. - n_rows(int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. - n_cols(int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Only - specified if fig and ax are None. + args: positional arguments to be passed to the + matplotlib.pyplot.scatter function. kwargs: keyword arguments to be passed to the - matplotlib.pyplot.scatter function; + matplotlib.pyplot.scatter function. Returns: - fig (figure): figure object in which the graphs are plotted. + Figure object in which the graphs are plotted. """ - from ..exploratory.visualization.representation import plot_scatter + from ..exploratory.visualization.representation import ScatterPlot - return plot_scatter(self, *args, **kwargs) + return ScatterPlot(self, *args, **kwargs).plot() - def to_basis(self, basis, **kwargs): + def to_basis(self, basis: Basis, **kwargs: Any) -> FDataBasis: """Return the basis representation of the object. Args: basis(Basis): basis object in which the functional data are going to be represented. - **kwargs: keyword arguments to be passed to + kwargs: keyword arguments to be passed to FDataBasis.from_data(). Returns: @@ -823,64 +848,86 @@ def to_basis(self, basis, **kwargs): from ..preprocessing.smoothing import BasisSmoother if self.dim_domain != basis.dim_domain: - raise ValueError(f"The domain of the function has " - f"dimension {self.dim_domain} " - f"but the domain of the basis has " - f"dimension {basis.dim_domain}") + raise ValueError( + f"The domain of the function has " + f"dimension {self.dim_domain} " + f"but the domain of the basis has " + f"dimension {basis.dim_domain}", + ) elif self.dim_codomain != basis.dim_codomain: - raise ValueError(f"The codomain of the function has " - f"dimension {self.dim_codomain} " - f"but the codomain of the basis has " - f"dimension {basis.dim_codomain}") + raise ValueError( + f"The codomain of the function has " + f"dimension {self.dim_codomain} " + f"but the codomain of the basis has " + f"dimension {basis.dim_codomain}", + ) # Readjust the domain range if there was not an explicit one - if basis._domain_range is None: + if not basis.is_domain_range_fixed(): basis = basis.copy(domain_range=self.domain_range) smoother = BasisSmoother( basis=basis, **kwargs, - return_basis=True) + return_basis=True, + ) return smoother.fit_transform(self) - def to_grid(self, grid_points=None, *, sample_points=None): + def to_grid( # noqa: D102 + self: T, + grid_points: Optional[GridPointsLike] = None, + *, + sample_points: Optional[GridPointsLike] = None, + ) -> T: if sample_points is not None: - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) grid_points = sample_points - if grid_points is None: - grid_points = self.grid_points - - return self.copy(data_matrix=self.evaluate(grid_points, grid=True), - grid_points=grid_points) - - def copy(self, *, - deep=False, # For Pandas compatibility - data_matrix=None, - grid_points=None, - sample_points=None, - domain_range=None, - dataset_name=None, - argument_names=None, - coordinate_names=None, - sample_names=None, - extrapolation=None, - interpolation=None): - """Returns a copy of the FDataGrid. + grid_points = ( + self.grid_points + if grid_points is None + else _to_grid_points(grid_points) + ) + + return self.copy( + data_matrix=self.evaluate(grid_points, grid=True), + grid_points=grid_points, + ) + + def copy( # noqa: WPS211 + self: T, + *, + deep: bool = False, # For Pandas compatibility + data_matrix: Optional[ArrayLike] = None, + grid_points: Optional[GridPointsLike] = None, + sample_points: Optional[GridPointsLike] = None, + domain_range: Optional[DomainRangeLike] = None, + dataset_name: Optional[str] = None, + argument_names: Optional[LabelTupleLike] = None, + coordinate_names: Optional[LabelTupleLike] = None, + sample_names: Optional[LabelTupleLike] = None, + extrapolation: Optional[ExtrapolationLike] = None, + interpolation: Optional[Evaluator] = None, + ) -> T: + """ + Return a copy of the FDataGrid. If an argument is provided the corresponding attribute in the new copy is updated. """ - if sample_points is not None: - warnings.warn("Parameter sample_points is deprecated. Use the " - "parameter grid_points instead.", - DeprecationWarning) + warnings.warn( + "Parameter sample_points is deprecated. Use the " + "parameter grid_points instead.", + DeprecationWarning, + ) grid_points = sample_points if data_matrix is None: @@ -888,7 +935,7 @@ def copy(self, *, data_matrix = self.data_matrix if grid_points is None: - # Sample points won`t be writeable + # Grid points won`t be writeable grid_points = self.grid_points if domain_range is None: @@ -915,139 +962,218 @@ def copy(self, *, if interpolation is None: interpolation = self.interpolation - return FDataGrid(data_matrix, grid_points=grid_points, - domain_range=domain_range, - dataset_name=dataset_name, - argument_names=argument_names, - coordinate_names=coordinate_names, - sample_names=sample_names, - extrapolation=extrapolation, - interpolation=interpolation) + return FDataGrid( + data_matrix, + grid_points=grid_points, + domain_range=domain_range, + dataset_name=dataset_name, + argument_names=argument_names, + coordinate_names=coordinate_names, + sample_names=sample_names, + extrapolation=extrapolation, + interpolation=interpolation, + ) + + def restrict( + self: T, + domain_range: DomainRangeLike, + ) -> T: + """ + Restrict the functions to a new domain range. - def shift(self, shifts, *, restrict_domain=False, extrapolation=None, - eval_points=None): - """Perform a shift of the curves. + Args: + domain_range: New domain range. + + Returns: + Restricted function. + + """ + domain_range = _to_domain_range(domain_range) + assert all( + c <= a < b <= d # noqa: WPS228 + for ((a, b), (c, d)) in zip(domain_range, self.domain_range) + ) + + index_list = [] + new_grid_points = [] + + # Eliminate points outside the new range. + for dr, grid_points in zip( + domain_range, + self.grid_points, + ): + keep_index = ( + (dr[0] <= grid_points) + & (grid_points <= dr[1]) + ) + + index_list.append(keep_index) + + new_grid_points.append( + grid_points[keep_index], + ) + + data_matrix = self.data_matrix[tuple([slice(None)] + index_list)] + + return self.copy( + domain_range=domain_range, + grid_points=new_grid_points, + data_matrix=data_matrix, + ) + + def shift( + self, + shifts: Union[ArrayLike, float], + *, + restrict_domain: bool = False, + extrapolation: Optional[ExtrapolationLike] = None, + grid_points: Optional[GridPointsLike] = None, + ) -> FDataGrid: + r""" + Perform a shift of the curves. + + The i-th shifted function :math:`y_i` has the form + + .. math:: + y_i(t) = x_i(t + \delta_i) + + where :math:`x_i` is the i-th original function and :math:`delta_i` is + the shift performed for that function, that must be a vector in the + domain space. + + Note that a positive shift moves the graph of the function in the + negative direction and vice versa. Args: - shifts (array_like or numeric): List with the shifts + shifts: List with the shifts corresponding for each sample or numeric with the shift to apply to all samples. - restrict_domain (bool, optional): If True restricts the domain to - avoid evaluate points outside the domain using extrapolation. + restrict_domain: If True restricts the domain to avoid the + evaluation of points outside the domain using extrapolation. Defaults uses extrapolation. - extrapolation (str or Extrapolation, optional): Controls the + extrapolation: Controls the extrapolation mode for elements outside the domain range. By default uses the method defined in fd. See extrapolation to more information. - eval_points (array_like, optional): Set of points where + grid_points: Grid of points where the functions are evaluated to obtain the discrete - representation of the object to operate. If an empty list the + representation of the object to operate. If ``None`` the current grid_points are used to unificate the domain of the shifted data. Returns: - :class:`FDataGrid` with the shifted data. - """ - - if np.isscalar(shifts): - shifts = [shifts] - - shifts = np.array(shifts) - - # Case unidimensional treated as the multidimensional - if self.dim_domain == 1 and shifts.ndim == 1 and shifts.shape[0] != 1: - shifts = shifts[:, np.newaxis] - - # Case same shift for all the curves - if shifts.shape[0] == self.dim_domain and shifts.ndim == 1: - - # Column vector with shapes - shifts = np.atleast_2d(shifts).T - - grid_points = self.grid_points + shifts - domain_range = self.domain_range + shifts - - return self.copy(grid_points=grid_points, - domain_range=domain_range) - if shifts.shape[0] != self.n_samples: - raise ValueError(f"shifts vector ({shifts.shape[0]}) must have the" - f" same length than the number of samples " - f"({self.n_samples})") - - if eval_points is None: - eval_points = self.grid_points - else: - eval_points = np.atleast_2d(eval_points) - - if restrict_domain: - domain = np.asarray(self.domain_range) - a = domain[:, 0] - np.atleast_1d(np.min(np.min(shifts, axis=1), 0)) - b = domain[:, 1] - np.atleast_1d(np.max(np.max(shifts, axis=1), 0)) - - domain = np.vstack((a, b)).T - - eval_points = [eval_points[i][ - np.logical_and(eval_points[i] >= domain[i, 0], - eval_points[i] <= domain[i, 1])] - for i in range(self.dim_domain)] + Shifted functions. - else: - domain = self.domain_range - - eval_points = np.asarray(eval_points) - - eval_points_repeat = np.repeat(eval_points[np.newaxis, :], - self.n_samples, axis=0) - - # Solve problem with cartesian and matrix indexing - if self.dim_domain > 1: - shifts[:, :2] = np.flip(shifts[:, :2], axis=1) - - shifts = np.repeat(shifts[..., np.newaxis], - eval_points.shape[1], axis=2) - - eval_points_shifted = eval_points_repeat + shifts - - data_matrix = self.evaluate(eval_points_shifted, - extrapolation=extrapolation, - aligned=False, - grid=True) - - return self.copy(data_matrix=data_matrix, grid_points=eval_points, - domain_range=domain) + Examples: + >>> import numpy as np + >>> import skfda + >>> + >>> t = np.linspace(0, 1, 6) + >>> x = np.array([t, t**2, t**3]) + >>> fd = FDataGrid(x, t) + >>> fd.domain_range[0] + (0.0, 1.0) + >>> fd.grid_points[0] + array([ 0. , 0.2, 0.4, 0.6, 0.8, 1. ]) + >>> fd.data_matrix[..., 0] + array([[ 0. , 0.2 , 0.4 , 0.6 , 0.8 , 1. ], + [ 0. , 0.04 , 0.16 , 0.36 , 0.64 , 1. ], + [ 0. , 0.008, 0.064, 0.216, 0.512, 1. ]]) + + Shift all curves by the same amount: + + >>> shifted = fd.shift(0.2) + >>> shifted.domain_range[0] + (0.0, 1.0) + >>> shifted.grid_points[0] + array([ 0. , 0.2, 0.4, 0.6, 0.8, 1. ]) + >>> shifted.data_matrix[..., 0] + array([[ 0.2 , 0.4 , 0.6 , 0.8 , 1. , 1.2 ], + [ 0.04 , 0.16 , 0.36 , 0.64 , 1. , 1.36 ], + [ 0.008, 0.064, 0.216, 0.512, 1. , 1.488]]) + + + Different shift per curve: + + >>> shifted = fd.shift([-0.2, 0.0, 0.2]) + >>> shifted.domain_range[0] + (0.0, 1.0) + >>> shifted.grid_points[0] + array([ 0. , 0.2, 0.4, 0.6, 0.8, 1. ]) + >>> shifted.data_matrix[..., 0] + array([[-0.2 , 0. , 0.2 , 0.4 , 0.6 , 0.8 ], + [ 0. , 0.04 , 0.16 , 0.36 , 0.64 , 1. ], + [ 0.008, 0.064, 0.216, 0.512, 1. , 1.488]]) + + It is possible to restrict the domain to prevent the need for + extrapolations: + + >>> shifted = fd.shift([-0.3, 0.1, 0.2], restrict_domain=True) + >>> shifted.domain_range[0] + (0.3, 0.8) - def compose(self, fd, *, eval_points=None): + """ + grid_points = ( + self.grid_points if grid_points is None + else grid_points + ) + + return super().shift( + shifts=shifts, + restrict_domain=restrict_domain, + extrapolation=extrapolation, + grid_points=grid_points, + ) + + def compose( + self: T, + fd: T, + *, + eval_points: Optional[GridPointsLike] = None, + ) -> T: """Composition of functions. Performs the composition of functions. Args: - fd (:class:`FData`): FData object to make the composition. Should + fd: FData object to make the composition. Should have the same number of samples and image dimension equal to 1. - eval_points (array_like): Points to perform the evaluation. - """ + eval_points: Points to perform the evaluation. + + Returns: + Function representing the composition. + """ if self.dim_domain != fd.dim_codomain: - raise ValueError(f"Dimension of codomain of first function do not " - f"match with the domain of the second function " - f"({self.dim_domain})!=({fd.dim_codomain}).") + raise ValueError( + f"Dimension of codomain of first function do not " + f"match with the domain of the second function " + f"{self.dim_domain} != {fd.dim_codomain}.", + ) # All composed with same function if fd.n_samples == 1 and self.n_samples != 1: - fd = fd.copy(data_matrix=np.repeat(fd.data_matrix, self.n_samples, - axis=0)) + fd = fd.copy(data_matrix=np.repeat( + fd.data_matrix, + self.n_samples, + axis=0, + )) if fd.dim_domain == 1: if eval_points is None: try: eval_points = fd.grid_points[0] except AttributeError: - eval_points = np.linspace(*fd.domain_range[0], - constants.N_POINTS_COARSE_MESH) + eval_points = np.linspace( + *fd.domain_range[0], + constants.N_POINTS_COARSE_MESH, + ) eval_points_transformation = fd(eval_points) - data_matrix = self(eval_points_transformation, - aligned=False) + data_matrix = self( + eval_points_transformation, + aligned=False, + ) else: if eval_points is None: eval_points = fd.grid_points @@ -1056,69 +1182,93 @@ def compose(self, fd, *, eval_points=None): lengths = [len(ax) for ax in eval_points] - eval_points_transformation = np.empty((self.n_samples, - np.prod(lengths), - self.dim_domain)) + eval_points_transformation = np.empty(( + self.n_samples, + np.prod(lengths), + self.dim_domain, + )) for i in range(self.n_samples): eval_points_transformation[i] = np.array( - list(map(np.ravel, grid_transformation[i].T)) + list(map(np.ravel, grid_transformation[i].T)), ).T - data_matrix = self(eval_points_transformation, - aligned=False) + data_matrix = self( + eval_points_transformation, + aligned=False, + ) - return self.copy(data_matrix=data_matrix, - grid_points=eval_points, - domain_range=fd.domain_range, - argument_names=fd.argument_names) + return self.copy( + data_matrix=data_matrix, + grid_points=eval_points, + domain_range=fd.domain_range, + argument_names=fd.argument_names, + ) - def __str__(self): + def __str__(self) -> str: """Return str(self).""" - return ('Data set: ' + str(self.data_matrix) - + '\ngrid_points: ' + str(self.grid_points) - + '\ntime range: ' + str(self.domain_range)) + return ( + f"Data set: {self.data_matrix}\n" + f"grid_points: {self.grid_points}\n" + f"time range: {self.domain_range}" + ) - def __repr__(self): + def __repr__(self) -> str: """Return repr(self).""" - - return (f"FDataGrid(" - f"\n{repr(self.data_matrix)}," - f"\ngrid_points={repr(self.grid_points)}," - f"\ndomain_range={repr(self.domain_range)}," - f"\ndataset_name={repr(self.dataset_name)}," - f"\nargument_names={repr(self.argument_names)}," - f"\ncoordinate_names={repr(self.coordinate_names)}," - f"\nextrapolation={repr(self.extrapolation)}," - f"\ninterpolation={repr(self.interpolation)})").replace( - '\n', '\n ') - - def __getitem__(self, key): + return ( + f"FDataGrid(" # noqa: WPS221 + f"\n{self.data_matrix!r}," + f"\ngrid_points={self.grid_points!r}," + f"\ndomain_range={self.domain_range!r}," + f"\ndataset_name={self.dataset_name!r}," + f"\nargument_names={self.argument_names!r}," + f"\ncoordinate_names={self.coordinate_names!r}," + f"\nextrapolation={self.extrapolation!r}," + f"\ninterpolation={self.interpolation!r})" + ).replace( + '\n', + '\n ', + ) + + def __getitem__(self: T, key: Union[int, slice, np.ndarray]) -> T: """Return self[key].""" - key = _check_array_key(self.data_matrix, key) - return self.copy(data_matrix=self.data_matrix[key], - sample_names=np.array(self.sample_names)[key]) + return self.copy( + data_matrix=self.data_matrix[key], + sample_names=np.array(self.sample_names)[key], + ) ##################################################################### # Numpy methods ##################################################################### - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + def __array_ufunc__( + self, + ufunc: Any, + method: str, + *inputs: Any, + **kwargs: Any, + ) -> Any: for i in inputs: - if isinstance(i, FDataGrid) and not np.array_equal( - i.grid_points, self.grid_points): + if ( + isinstance(i, FDataGrid) + and not np.array_equal(i.grid_points, self.grid_points) + ): return NotImplemented - new_inputs = [i.data_matrix if isinstance(i, FDataGrid) - else i for i in inputs] + new_inputs = [ + i.data_matrix if isinstance(i, FDataGrid) + else i for i in inputs + ] outputs = kwargs.pop('out', None) if outputs: - new_outputs = [o.data_matrix if isinstance(o, FDataGrid) - else o for o in outputs] + new_outputs = [ + o.data_matrix if isinstance(o, FDataGrid) + else o for o in outputs + ] kwargs['out'] = tuple(new_outputs) else: new_outputs = (None,) * ufunc.nout @@ -1130,9 +1280,10 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): if ufunc.nout == 1: results = (results,) - results = tuple((result - if output is None else output) - for result, output in zip(results, new_outputs)) + results = tuple( + (result if output is None else output) + for result, output in zip(results, new_outputs) + ) results = [self.copy(data_matrix=r) for r in results] @@ -1142,12 +1293,13 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): # Pandas ExtensionArray methods ##################################################################### @property - def dtype(self): + def dtype(self) -> FDataGridDType: """The dtype for this extension array, FDataGridDType""" return FDataGridDType( grid_points=self.grid_points, domain_range=self.domain_range, - dim_codomain=self.dim_codomain) + dim_codomain=self.dim_codomain, + ) @property def nbytes(self) -> int: @@ -1155,59 +1307,70 @@ def nbytes(self) -> int: The number of bytes needed to store this object in memory. """ return self.data_matrix.nbytes + sum( - p.nbytes for p in self.grid_points) + p.nbytes for p in self.grid_points + ) - def isna(self): + def isna(self) -> np.ndarray: """ - A 1-D array indicating if each value is missing. + Return a 1-D array indicating if each value is missing. Returns: - na_values (np.ndarray): Positions of NA. + na_values: Positions of NA. """ - return np.all(np.isnan(self.data_matrix), - axis=tuple(range(1, self.data_matrix.ndim))) + return np.all( + np.isnan(self.data_matrix), + axis=tuple(range(1, self.data_matrix.ndim)), + ) -class FDataGridDType(pandas.api.extensions.ExtensionDtype): - """ - DType corresponding to FDataGrid in Pandas - """ +class FDataGridDType(pandas.api.extensions.ExtensionDtype): # type: ignore + """DType corresponding to FDataGrid in Pandas.""" + name = 'FDataGrid' kind = 'O' - type = FDataGrid + type = FDataGrid # noqa: WPS125 na_value = pandas.NA - def __init__(self, grid_points, dim_codomain, domain_range=None) -> None: - grid_points = _tuple_of_arrays(grid_points) + def __init__( + self, + grid_points: GridPointsLike, + dim_codomain: int, + domain_range: Optional[DomainRangeLike] = None, + ) -> None: + grid_points = _to_grid_points(grid_points) self.grid_points = tuple(tuple(s) for s in grid_points) if domain_range is None: - domain_range = np.array( - [(s[0], s[-1]) for s in self.grid_points]) + domain_range = tuple((s[0], s[-1]) for s in self.grid_points) - self.domain_range = _domain_range(domain_range) + self.domain_range = _to_domain_range(domain_range) self.dim_codomain = dim_codomain @classmethod - def construct_array_type(cls): + def construct_array_type(cls) -> Type[FDataGrid]: # noqa: D102 return FDataGrid def _na_repr(self) -> FDataGrid: - shape = ((1,) - + tuple(len(s) for s in self.grid_points) - + (self.dim_codomain,)) + shape = ( + (1,) + + tuple(len(s) for s in self.grid_points) + + (self.dim_codomain,) + ) data_matrix = np.full(shape=shape, fill_value=np.NaN) return FDataGrid( grid_points=self.grid_points, domain_range=self.domain_range, - data_matrix=data_matrix) + data_matrix=data_matrix, + ) def __eq__(self, other: Any) -> bool: """ + Compare dtype equality. + Rules for equality (similar to categorical): 1) Any FData is equal to the string 'category' 2) Any FData is equal to itself @@ -1218,12 +1381,38 @@ def __eq__(self, other: Any) -> bool: return other == self.name elif other is self: return True - else: - return (isinstance(other, FDataGridDType) - and self.dim_codomain == other.dim_codomain - and self.domain_range == other.domain_range - and self.grid_points == other.grid_points) + + return ( + isinstance(other, FDataGridDType) + and self.dim_codomain == other.dim_codomain + and self.domain_range == other.domain_range + and self.grid_points == other.grid_points + ) def __hash__(self) -> int: - return hash((self.grid_points, - self.domain_range, self.dim_codomain)) + return hash((self.grid_points, self.domain_range, self.dim_codomain)) + + +class _CoordinateIterator(Sequence[T]): + """Internal class to iterate through the image coordinates.""" + + def __init__(self, fdatagrid: T) -> None: + """Create an iterator through the image coordinates.""" + self._fdatagrid = fdatagrid + + def __getitem__(self, key: Union[int, slice]) -> T: + """Get a specific coordinate.""" + s_key = key + if isinstance(s_key, int): + s_key = slice(s_key, s_key + 1) + + coordinate_names = np.array(self._fdatagrid.coordinate_names)[s_key] + + return self._fdatagrid.copy( + data_matrix=self._fdatagrid.data_matrix[..., key], + coordinate_names=coordinate_names, + ) + + def __len__(self) -> int: + """Return the number of coordinates.""" + return self._fdatagrid.dim_codomain diff --git a/skfda/representation/interpolation.py b/skfda/representation/interpolation.py index 0384b5dd0..5ba146f8d 100644 --- a/skfda/representation/interpolation.py +++ b/skfda/representation/interpolation.py @@ -1,62 +1,122 @@ """ Module to interpolate functional data objects. """ - +from __future__ import annotations import abc - -from scipy.interpolate import (PchipInterpolator, UnivariateSpline, - RectBivariateSpline, RegularGridInterpolator) +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Iterable, + Sequence, + Tuple, + Union, + cast, +) import numpy as np +from scipy.interpolate import ( + PchipInterpolator, + RectBivariateSpline, + RegularGridInterpolator, + UnivariateSpline, +) + from .._utils import _to_array_maybe_ragged +from ._typing import ArrayLike from .evaluator import Evaluator +if TYPE_CHECKING: + from . import FData + +SplineCallable = Callable[..., np.ndarray] + class _SplineList(abc.ABC): - r"""ABC for list of interpolations.""" + """ABC for list of interpolations.""" - def __init__(self, fdatagrid, - interpolation_order=1, - smoothness_parameter=0.): + def __init__( + self, + fdatagrid: FData, + interpolation_order: Union[int, Sequence[int]] = 1, + smoothness_parameter: float = 0, + ): super().__init__() self.fdatagrid = fdatagrid self.interpolation_order = interpolation_order self.smoothness_parameter = smoothness_parameter + self.splines: Sequence[Sequence[SplineCallable]] + + # @abc.abstractmethod + # @property + # def splines(self) -> Sequence[SplineCallable]: + # pass @abc.abstractmethod - def _evaluate_one(self, spl, t, derivative=0): - """Evaluates one spline of the list.""" + def _evaluate_one( + self, + spline: SplineCallable, + eval_points: np.ndarray, + ) -> np.ndarray: + """Evaluate one spline of the list.""" pass - def _evaluate_codomain(self, spl_m, t, derivative=0): - """Evaluator of multidimensional sample""" - return np.array([self._evaluate_one(spl, t, derivative) - for spl in spl_m]).T - - def evaluate(self, fdata, eval_points, *, derivative=0, aligned=True): + def _evaluate_codomain( + self, + spline_list: Sequence[SplineCallable], + eval_points: np.ndarray, + ) -> np.ndarray: + """Evaluate a multidimensional sample.""" + return np.array([ + self._evaluate_one(spl, eval_points) + for spl in spline_list + ]).T + + def evaluate( + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: + + res: np.ndarray if aligned: + + eval_points = np.asarray(eval_points) + # Points evaluated inside the domain res = np.apply_along_axis( - self._evaluate_codomain, 1, - self.splines, eval_points, derivative) - res = res.reshape(fdata.n_samples, eval_points.shape[0], - fdata.dim_codomain) + self._evaluate_codomain, + 1, + self.splines, + eval_points, + ) + + res = res.reshape( + fdata.n_samples, + eval_points.shape[0], + fdata.dim_codomain, + ) else: - res = _to_array_maybe_ragged([self._evaluate_codomain( - s, e, derivative=derivative) - for s, e in zip(self.splines, eval_points)]) + eval_points = cast(Iterable[ArrayLike], eval_points) + + res = _to_array_maybe_ragged([ + self._evaluate_codomain(s, np.asarray(e)) + for s, e in zip(self.splines, eval_points) + ]) return res class _SplineList1D(_SplineList): - r"""List of interpolations for curves. + """List of interpolations for curves. List of interpolations for objects with domain dimension = 1. Calling internally during the creation of the @@ -92,33 +152,45 @@ class _SplineList1D(_SplineList): """ - def __init__(self, fdatagrid, - interpolation_order=1, - smoothness_parameter=0., - monotone=False): + def __init__( + self, + fdatagrid: FData, + interpolation_order: Union[int, Sequence[int]] = 1, + smoothness_parameter: float = 0, + monotone: bool = False, + ): super().__init__( fdatagrid=fdatagrid, interpolation_order=interpolation_order, - smoothness_parameter=smoothness_parameter) + smoothness_parameter=smoothness_parameter, + ) self.monotone = monotone - if self.interpolation_order > 5 or self.interpolation_order < 1: - raise ValueError(f"Invalid degree of interpolation " - f"({self.interpolation_order}). Must be " - f"an integer greater than 0 and lower or " - f"equal than 5.") + if ( + isinstance(self.interpolation_order, Sequence) + or not 1 <= self.interpolation_order <= 5 + ): + raise ValueError( + f"Invalid degree of interpolation " + f"({self.interpolation_order}). Must be " + f"an integer greater than 0 and lower or " + f"equal than 5.", + ) if self.monotone and self.smoothness_parameter != 0: - raise ValueError("Smoothing interpolation is not supported with " - "monotone interpolation") - - if self.monotone and (self.interpolation_order == 2 - or self.interpolation_order == 4): - raise ValueError(f"monotone interpolation of degree " - f"{self.interpolation_order}" - f"not supported.") + raise ValueError( + "Smoothing interpolation is not supported with " + "monotone interpolation", + ) + + if self.monotone and self.interpolation_order in {2, 4}: + raise ValueError( + f"monotone interpolation of degree " + f"{self.interpolation_order}" + f"not supported.", + ) # Monotone interpolation of degree 1 is performed with linear spline monotone = self.monotone @@ -128,31 +200,44 @@ def __init__(self, fdatagrid, grid_points = fdatagrid.grid_points[0] if monotone: - def constructor(data): - """Constructs an unidimensional cubic monotone interpolation""" + def constructor( # noqa: WPS430 + data: np.ndarray, + ) -> SplineCallable: + """Construct an unidimensional cubic monotone interpolation.""" return PchipInterpolator(grid_points, data) else: - def constructor(data): - """Constructs an unidimensional interpolation""" + def constructor( # noqa: WPS430, WPS440 + data: np.ndarray, + ) -> SplineCallable: + """Construct an unidimensional interpolation.""" return UnivariateSpline( - grid_points, data, + grid_points, + data, s=self.smoothness_parameter, - k=self.interpolation_order) + k=self.interpolation_order, + ) self.splines = np.apply_along_axis( - constructor, 1, fdatagrid.data_matrix) - - def _evaluate_one(self, spl, t, derivative=0): + constructor, + 1, + fdatagrid.data_matrix, + ) + + def _evaluate_one( + self, + spline: SplineCallable, + eval_points: np.ndarray, + ) -> np.ndarray: try: - return spl(t, derivative)[:, 0] + return spline(eval_points)[:, 0] except ValueError: - return np.zeros_like(t) + return np.zeros_like(eval_points) class _SplineList2D(_SplineList): - r"""List of interpolations for surfaces. + """List of interpolations for surfaces. List of interpolations for objects with domain dimension = 2. Calling internally during the creationg of the @@ -187,54 +272,70 @@ class _SplineList2D(_SplineList): """ - def __init__(self, fdatagrid, - interpolation_order=1, - smoothness_parameter=0.): + def __init__( + self, + fdatagrid: FData, + interpolation_order: Union[int, Sequence[int]] = 1, + smoothness_parameter: float = 0, + ): super().__init__( fdatagrid=fdatagrid, interpolation_order=interpolation_order, - smoothness_parameter=smoothness_parameter) + smoothness_parameter=smoothness_parameter, + ) - if np.isscalar(self.interpolation_order): - kx = ky = self.interpolation_order - elif len(self.interpolation_order) != 2: - raise ValueError("k should be numeric or a tuple of length 2.") - else: + if isinstance(self.interpolation_order, int): + kx = self.interpolation_order + ky = kx + elif len(self.interpolation_order) == 2: kx = self.interpolation_order[0] ky = self.interpolation_order[1] + else: + raise ValueError("k should be numeric or a tuple of length 2.") if kx > 5 or kx <= 0 or ky > 5 or ky <= 0: - raise ValueError(f"Invalid degree of interpolation ({kx},{ky}). " - f"Must be an integer greater than 0 and lower or " - f"equal than 5.") + raise ValueError( + f"Invalid degree of interpolation ({kx},{ky}). " + f"Must be an integer greater than 0 and lower or " + f"equal than 5.", + ) # Matrix of splines - self.splines = np.empty( - (fdatagrid.n_samples, fdatagrid.dim_codomain), dtype=object) + splines = np.empty( + (fdatagrid.n_samples, fdatagrid.dim_codomain), + dtype=object, + ) for i in range(fdatagrid.n_samples): for j in range(fdatagrid.dim_codomain): - self.splines[i, j] = RectBivariateSpline( + splines[i, j] = RectBivariateSpline( fdatagrid.grid_points[0], fdatagrid.grid_points[1], fdatagrid.data_matrix[i, :, :, j], - kx=kx, ky=ky, - s=self.smoothness_parameter) + kx=kx, + ky=ky, + s=self.smoothness_parameter, + ) + + self.splines = splines - def _evaluate_one(self, spl, t, derivative=0): - if np.isscalar(derivative): - derivative = 2 * [derivative] - elif len(derivative) != 2: - raise ValueError("derivative should be a numeric value " - "or a tuple of length 2 with (dx,dy).") + def _evaluate_one( + self, + spline: SplineCallable, + eval_points: np.ndarray, + ) -> np.ndarray: - return spl(t[:, 0], t[:, 1], dx=derivative[0], dy=derivative[1], - grid=False) + return spline( + eval_points[:, 0], + eval_points[:, 1], + grid=False, + ) class _SplineListND(_SplineList): - r"""List of interpolations. + """ + List of interpolations. List of interpolations for objects with domain dimension > 2. Calling internally during the creationg of the @@ -259,18 +360,23 @@ class _SplineListND(_SplineList): """ - def __init__(self, fdatagrid, - interpolation_order=1, - smoothness_parameter=0.): - + def __init__( + self, + fdatagrid: FData, + interpolation_order: Union[int, Sequence[int]] = 1, + smoothness_parameter: float = 0, + ) -> None: super().__init__( fdatagrid=fdatagrid, interpolation_order=interpolation_order, - smoothness_parameter=smoothness_parameter) + smoothness_parameter=smoothness_parameter, + ) if self.smoothness_parameter != 0: - raise ValueError("Smoothing interpolation is only supported with " - "domain dimension up to 2, s should be 0.") + raise ValueError( + "Smoothing interpolation is only supported with " + "domain dimension up to 2.", + ) # Parses method of interpolation if self.interpolation_order == 0: @@ -278,29 +384,38 @@ def __init__(self, fdatagrid, elif self.interpolation_order == 1: method = 'linear' else: - raise ValueError("interpolation order should be 0 (nearest) or 1 " - "(linear).") + raise ValueError( + "interpolation order should be 0 (nearest) or 1 (linear).", + ) - self.splines = np.empty( - (fdatagrid.n_samples, fdatagrid.dim_codomain), dtype=object) + splines = np.empty( + (fdatagrid.n_samples, fdatagrid.dim_codomain), + dtype=object, + ) for i in range(fdatagrid.n_samples): for j in range(fdatagrid.dim_codomain): - self.splines[i, j] = RegularGridInterpolator( - fdatagrid.grid_points, fdatagrid.data_matrix[i, ..., j], - method, False) + splines[i, j] = RegularGridInterpolator( + fdatagrid.grid_points, + fdatagrid.data_matrix[i, ..., j], + method=method, + bounds_error=False, + ) - def _evaluate_one(self, spl, t, derivative=0): + self.splines = splines - if derivative != 0: - raise ValueError("derivates not suported for functional data " - " with domain dimension greater than 2.") + def _evaluate_one( + self, + spline: SplineCallable, + eval_points: np.ndarray, + ) -> np.ndarray: - return spl(t) + return spline(eval_points) class SplineInterpolation(Evaluator): - r"""Spline interpolation of :class:`FDataGrid`. + """ + Spline interpolation. Spline interpolation of discretized functional objects. Implements different interpolation methods based in splines, using the sample @@ -326,88 +441,93 @@ class SplineInterpolation(Evaluator): """ - def __init__(self, interpolation_order=1, *, smoothness_parameter=0., - monotone=False): - r"""Constructor of the SplineInterpolation. - - Args: - interpolation_order (int, optional): Order of the interpolation, 1 - for linear interpolation, 2 for cuadratic, 3 for cubic and so - on. In case of curves and surfaces there is available - interpolation up to degree 5. For higher dimensional objects - only linear or nearest interpolation is available. Default - lineal interpolation. - smoothness_parameter (float, optional): Penalisation to perform - smoothness interpolation. Option only available for curves and - surfaces. If 0 the residuals of the interpolation will be 0. - Defaults 0. - monotone (boolean, optional): Performs monotone interpolation in - curves using a PCHIP interpolation. Only valid for curves - (domain dimension equal to 1) and interpolation order equal - to 1 or 3. - Defaults false. - - """ + def __init__( + self, + interpolation_order: Union[int, Sequence[int]] = 1, + *, + smoothness_parameter: float = 0, + monotone: bool = False, + ) -> None: self._interpolation_order = interpolation_order self._smoothness_parameter = smoothness_parameter self._monotone = monotone @property - def interpolation_order(self): - "Returns the interpolation order" - return self._interpolation_order + def interpolation_order(self) -> Union[int, Tuple[int, ...]]: + """Interpolation order.""" + + return ( + self._interpolation_order + if isinstance(self._interpolation_order, int) + else tuple(self._interpolation_order) + ) @property - def smoothness_parameter(self): - "Returns the smoothness parameter" + def smoothness_parameter(self) -> float: + """Smoothness parameter.""" return self._smoothness_parameter @property - def monotone(self): - "Returns flag to perform monotone interpolation" + def monotone(self) -> bool: + """Flag to perform monotone interpolation.""" return self._monotone - def _build_interpolator(self, fdatagrid): + def _build_interpolator( + self, + fdatagrid: FData, + ) -> _SplineList: if fdatagrid.dim_domain == 1: return _SplineList1D( fdatagrid=fdatagrid, interpolation_order=self.interpolation_order, smoothness_parameter=self.smoothness_parameter, - monotone=self.monotone) + monotone=self.monotone, + ) elif self.monotone: - raise ValueError("Monotone interpolation is only supported with " - "domain dimension equal to 1.") + raise ValueError( + "Monotone interpolation is only supported with " + "domain dimension equal to 1.", + ) elif fdatagrid.dim_domain == 2: return _SplineList2D( fdatagrid=fdatagrid, interpolation_order=self.interpolation_order, - smoothness_parameter=self.smoothness_parameter) - - else: - return _SplineListND( - fdatagrid=fdatagrid, - interpolation_order=self.interpolation_order, - smoothness_parameter=self.smoothness_parameter) + smoothness_parameter=self.smoothness_parameter, + ) - def evaluate(self, fdata, eval_points, *, aligned=True): + return _SplineListND( + fdatagrid=fdatagrid, + interpolation_order=self.interpolation_order, + smoothness_parameter=self.smoothness_parameter, + ) + + def _evaluate( # noqa: D102 + self, + fdata: FData, + eval_points: Union[ArrayLike, Iterable[ArrayLike]], + *, + aligned: bool = True, + ) -> np.ndarray: spline_list = self._build_interpolator(fdata) return spline_list.evaluate(fdata, eval_points, aligned=aligned) - def __repr__(self): - """repr method of the interpolation""" - return (f"{type(self).__name__}(" - f"interpolation_order={self.interpolation_order}, " - f"smoothness_parameter={self.smoothness_parameter}, " - f"monotone={self.monotone})") - - def __eq__(self, other): - """Equality operator between SplineInterpolation""" - return (super().__eq__(other) and - self.interpolation_order == other.interpolation_order and - self.smoothness_parameter == other.smoothness_parameter and - self.monotone == other.monotone) + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(" + f"interpolation_order={self.interpolation_order}, " + f"smoothness_parameter={self.smoothness_parameter}, " + f"monotone={self.monotone})" + ) + + def __eq__(self, other: Any) -> bool: + return ( + super().__eq__(other) + and self.interpolation_order == other.interpolation_order + and self.smoothness_parameter == other.smoothness_parameter + and self.monotone == other.monotone + ) diff --git a/tests/test_basis.py b/tests/test_basis.py index 276bd23fc..faff95c2f 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -19,290 +19,480 @@ class TestBasis(unittest.TestCase): + """Tests of basis and FDataBasis.""" # def setUp(self): could be defined for set up before any test - def test_from_data_cholesky(self): + def test_from_data_cholesky(self) -> None: + """Test basis conversion using Cholesky method.""" t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) basis = BSpline((0, 1), n_basis=5) np.testing.assert_array_almost_equal( - FDataBasis.from_data(x, grid_points=t, basis=basis, - method='cholesky' - ).coefficients.round(2), - np.array([[1., 2.78, -3., -0.78, 1.]]) + FDataBasis.from_data( + x, + grid_points=t, + basis=basis, + method='cholesky', + ).coefficients.round(2), + np.array([[1.0, 2.78, -3.0, -0.78, 1.0]]), ) - def test_from_data_qr(self): + def test_from_data_qr(self) -> None: + """Test basis conversion using QR method.""" t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) basis = BSpline((0, 1), n_basis=5) np.testing.assert_array_almost_equal( - FDataBasis.from_data(x, grid_points=t, basis=basis, - method='qr' - ).coefficients.round(2), - np.array([[1., 2.78, -3., -0.78, 1.]]) + FDataBasis.from_data( + x, + grid_points=t, + basis=basis, + method='qr', + ).coefficients.round(2), + np.array([[1.0, 2.78, -3.0, -0.78, 1.0]]), ) - def test_basis_inner_matrix(self): + def test_basis_inner_matrix(self) -> None: + """Test the inner product matrix of FDataBasis objects.""" + basis = Monomial(n_basis=3) + np.testing.assert_array_almost_equal( - Monomial(n_basis=3).inner_product_matrix(), - [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) + basis.inner_product_matrix(), + [ + [1, 1 / 2, 1 / 3], # noqa: WPS204 + [1 / 2, 1 / 3, 1 / 4], # noqa: WPS204 + [1 / 3, 1 / 4, 1 / 5], + ], + ) np.testing.assert_array_almost_equal( - Monomial(n_basis=3).inner_product_matrix(Monomial(n_basis=3)), - [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) + basis.inner_product_matrix(basis), + [ + [1, 1 / 2, 1 / 3], + [1 / 2, 1 / 3, 1 / 4], + [1 / 3, 1 / 4, 1 / 5], + ], + ) np.testing.assert_array_almost_equal( - Monomial(n_basis=3).inner_product_matrix(Monomial(n_basis=4)), - [[1, 1 / 2, 1 / 3, 1 / 4], - [1 / 2, 1 / 3, 1 / 4, 1 / 5], - [1 / 3, 1 / 4, 1 / 5, 1 / 6]]) + basis.inner_product_matrix(Monomial(n_basis=4)), + [ + [1, 1 / 2, 1 / 3, 1 / 4], + [1 / 2, 1 / 3, 1 / 4, 1 / 5], + [1 / 3, 1 / 4, 1 / 5, 1 / 6], + ], + ) # TODO testing with other basis - def test_basis_gram_matrix_monomial(self): - + def test_basis_gram_matrix_monomial(self) -> None: + """Test the Gram matrix with monomial basis.""" basis = Monomial(n_basis=3) gram_matrix = basis.gram_matrix() - gram_matrix_numerical = basis._gram_matrix_numerical() - gram_matrix_res = np.array([[1, 1 / 2, 1 / 3], - [1 / 2, 1 / 3, 1 / 4], - [1 / 3, 1 / 4, 1 / 5]]) + gram_matrix_numerical = basis._gram_matrix_numerical() # noqa: WPS437 + gram_matrix_res = np.array([ + [1, 1 / 2, 1 / 3], + [1 / 2, 1 / 3, 1 / 4], + [1 / 3, 1 / 4, 1 / 5], + ]) np.testing.assert_allclose( - gram_matrix, gram_matrix_res) + gram_matrix, + gram_matrix_res, + ) np.testing.assert_allclose( - gram_matrix_numerical, gram_matrix_res) - - def test_basis_gram_matrix_fourier(self): + gram_matrix_numerical, + gram_matrix_res, + ) + def test_basis_gram_matrix_fourier(self) -> None: + """Test the Gram matrix with fourier basis.""" basis = Fourier(n_basis=3) gram_matrix = basis.gram_matrix() - gram_matrix_numerical = basis._gram_matrix_numerical() + gram_matrix_numerical = basis._gram_matrix_numerical() # noqa: WPS437 gram_matrix_res = np.identity(3) np.testing.assert_allclose( - gram_matrix, gram_matrix_res) + gram_matrix, + gram_matrix_res, + ) np.testing.assert_allclose( - gram_matrix_numerical, gram_matrix_res, atol=1e-15, rtol=1e-15) - - def test_basis_gram_matrix_bspline(self): + gram_matrix_numerical, + gram_matrix_res, + atol=1e-15, + rtol=1e-15, + ) + def test_basis_gram_matrix_bspline(self) -> None: + """Test the Gram matrix with B-spline basis.""" basis = BSpline(n_basis=6) gram_matrix = basis.gram_matrix() - gram_matrix_numerical = basis._gram_matrix_numerical() - gram_matrix_res = np.array( - [[0.04761905, 0.02916667, 0.00615079, - 0.00039683, 0., 0.], - [0.02916667, 0.07380952, 0.05208333, - 0.01145833, 0.00014881, 0.], - [0.00615079, 0.05208333, 0.10892857, 0.07098214, - 0.01145833, 0.00039683], - [0.00039683, 0.01145833, 0.07098214, 0.10892857, - 0.05208333, 0.00615079], - [0., 0.00014881, 0.01145833, 0.05208333, - 0.07380952, 0.02916667], - [0., 0., 0.00039683, 0.00615079, - 0.02916667, 0.04761905]]) + gram_matrix_numerical = basis._gram_matrix_numerical() # noqa: WPS437 + gram_matrix_res = np.array([ + [0.04761905, 0.02916667, 0.00615079, 0.00039683, 0, 0], + [0.02916667, 0.07380952, 0.05208333, 0.01145833, 0.00014881, 0], + [ # noqa: WPS317 + 0.00615079, 0.05208333, 0.10892857, + 0.07098214, 0.01145833, 0.00039683, + ], + [ # noqa: WPS317 + 0.00039683, 0.01145833, 0.07098214, + 0.10892857, 0.05208333, 0.00615079, + ], + [0, 0.00014881, 0.01145833, 0.05208333, 0.07380952, 0.02916667], + [0, 0, 0.00039683, 0.00615079, 0.02916667, 0.04761905], + ]) np.testing.assert_allclose( - gram_matrix, gram_matrix_res, rtol=1e-4) + gram_matrix, + gram_matrix_res, + rtol=1e-4, + ) np.testing.assert_allclose( - gram_matrix_numerical, gram_matrix_res, rtol=1e-4) + gram_matrix_numerical, + gram_matrix_res, + rtol=1e-4, + ) - def test_basis_basis_inprod(self): + def test_basis_basis_inprod(self) -> None: + """Test inner product between different basis.""" monomial = Monomial(n_basis=4) bspline = BSpline(n_basis=5, order=4) np.testing.assert_allclose( monomial.inner_product_matrix(bspline), - np.array( - [[0.12499983, 0.25000035, 0.24999965, 0.25000035, 0.12499983], - [0.01249991, 0.07500017, 0.12499983, 0.17500017, 0.11249991], - [0.00208338, 0.02916658, 0.07083342, 0.12916658, 0.10208338], - [0.00044654, 0.01339264, 0.04375022, 0.09910693, 0.09330368] - ]), rtol=1e-3) + np.array([ + [0.12499983, 0.25000035, 0.24999965, 0.25000035, 0.12499983], + [0.01249991, 0.07500017, 0.12499983, 0.17500017, 0.11249991], + [0.00208338, 0.02916658, 0.07083342, 0.12916658, 0.10208338], + [0.00044654, 0.01339264, 0.04375022, 0.09910693, 0.09330368], + ]), + rtol=1e-3, + ) np.testing.assert_array_almost_equal( monomial.inner_product_matrix(bspline), - bspline.inner_product_matrix(monomial).T + bspline.inner_product_matrix(monomial).T, ) - def test_basis_fdatabasis_inprod(self): + def test_basis_fdatabasis_inprod(self) -> None: + """Test inner product between different basis expansions.""" monomial = Monomial(n_basis=4) bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_allclose( - inner_product_matrix(monomial, bsplinefd), - np.array([[2., 7., 12.], - [1.29626206, 3.79626206, 6.29626206], - [0.96292873, 2.62959539, 4.29626206], - [0.7682873, 2.0182873, 3.2682873]]), rtol=1e-4) + inner_product_matrix(monomial.to_basis(), bsplinefd), + np.array([ + [2.0, 7.0, 12.0], + [1.29626206, 3.79626206, 6.29626206], + [0.96292873, 2.62959539, 4.29626206], + [0.7682873, 2.0182873, 3.2682873], + ]), + rtol=1e-4, + ) - def test_fdatabasis_fdatabasis_inprod(self): + def test_fdatabasis_fdatabasis_inprod(self) -> None: + """Test inner product between FDataBasis objects.""" monomial = Monomial(n_basis=4) - monomialfd = FDataBasis(monomial, [[5, 4, 1, 0], - [4, 2, 1, 0], - [4, 1, 6, 4], - [4, 5, 0, 1], - [5, 6, 2, 0]]) + monomialfd = FDataBasis( + monomial, + [ + [5, 4, 1, 0], + [4, 2, 1, 0], + [4, 1, 6, 4], + [4, 5, 0, 1], + [5, 6, 2, 0], + ], + ) bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_allclose( inner_product_matrix(monomialfd, bsplinefd), - np.array([[16.14797697, 52.81464364, 89.4813103], - [11.55565285, 38.22211951, 64.88878618], - [18.14698361, 55.64698361, 93.14698361], - [15.2495976, 48.9995976, 82.7495976], - [19.70392982, 63.03676315, 106.37009648]]), - rtol=1e-4) - - def test_comutativity_inprod(self): + np.array([ + [16.14797697, 52.81464364, 89.4813103], + [11.55565285, 38.22211951, 64.88878618], + [18.14698361, 55.64698361, 93.14698361], + [15.2495976, 48.9995976, 82.7495976], + [19.70392982, 63.03676315, 106.37009648], + ]), + rtol=1e-4, + ) + + def test_comutativity_inprod(self) -> None: + """Test commutativity of the inner product.""" monomial = Monomial(n_basis=4) bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_allclose( - inner_product_matrix(bsplinefd, monomial), - np.transpose(inner_product_matrix(monomial, bsplinefd)) + inner_product_matrix(bsplinefd, monomial.to_basis()), + np.transpose(inner_product_matrix(monomial.to_basis(), bsplinefd)), ) - def test_fdatabasis__add__(self): + def test_concatenate(self) -> None: + """Test concatenation of two FDataBasis.""" + sample1 = np.arange(0, 10) + sample2 = np.arange(10, 20) + fd1 = FDataGrid([sample1]).to_basis(Fourier(n_basis=5)) + fd2 = FDataGrid([sample2]).to_basis(Fourier(n_basis=5)) + + fd = concatenate([fd1, fd2]) + + np.testing.assert_equal(fd.n_samples, 2) + np.testing.assert_equal(fd.dim_codomain, 1) + np.testing.assert_equal(fd.dim_domain, 1) + np.testing.assert_array_equal( + fd.coefficients, + np.concatenate([fd1.coefficients, fd2.coefficients]), + ) + + +class TestFDataBasisOperations(unittest.TestCase): + """Test FDataBasis operations.""" + + def test_fdatabasis_add(self) -> None: + """Test addition of FDataBasis.""" monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) - self.assertTrue((monomial1 + monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 4, 6], [4, 6, 8]]))) - self.assertTrue((monomial2 + 1).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 2, 3], [4, 4, 5]]))) - self.assertTrue((1 + monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 2, 3], [4, 4, 5]]))) - self.assertTrue((monomial2 + [1, 2]).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 2, 3], [5, 4, 5]]))) - self.assertTrue(([1, 2] + monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 2, 3], [5, 4, 5]]))) + self.assertTrue( + (monomial1 + monomial2).equals( + FDataBasis( + Monomial(n_basis=3), + [[2, 4, 6], [4, 6, 8]], + ), + ), + ) with np.testing.assert_raises(TypeError): - monomial2 + FDataBasis(Fourier(n_basis=3), - [[2, 2, 3], [5, 4, 5]]) + monomial2 + FDataBasis( # noqa: WPS428 + Fourier(n_basis=3), + [[2, 2, 3], [5, 4, 5]], + ) - def test_fdatabasis__sub__(self): + def test_fdatabasis_sub(self) -> None: + """Test subtraction of FDataBasis.""" monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) - self.assertTrue((monomial1 - monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[0, 0, 0], [-2, -2, -2]]))) - self.assertTrue((monomial2 - 1).equals( - FDataBasis(Monomial(n_basis=3), - [[0, 2, 3], [2, 4, 5]]))) - self.assertTrue((1 - monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[0, -2, -3], [-2, -4, -5]]))) - self.assertTrue((monomial2 - [1, 2]).equals( - FDataBasis(Monomial(n_basis=3), - [[0, 2, 3], [1, 4, 5]]))) - self.assertTrue(([1, 2] - monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[0, -2, -3], [-1, -4, -5]]))) + self.assertTrue( + (monomial1 - monomial2).equals( + FDataBasis( + Monomial(n_basis=3), + [[0, 0, 0], [-2, -2, -2]], + ), + ), + ) with np.testing.assert_raises(TypeError): - monomial2 - FDataBasis(Fourier(n_basis=3), - [[2, 2, 3], [5, 4, 5]]) + monomial2 - FDataBasis( # noqa: WPS428 + Fourier(n_basis=3), + [[2, 2, 3], [5, 4, 5]], + ) - def test_fdatabasis__mul__(self): - monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) + def test_fdatabasis_mul(self) -> None: + """Test multiplication of FDataBasis.""" + basis = Monomial(n_basis=3) + + monomial1 = FDataBasis(basis, [1, 2, 3]) + monomial2 = FDataBasis(basis, [[1, 2, 3], [3, 4, 5]]) + + self.assertTrue( + (monomial1 * 2).equals( + FDataBasis( + basis, + [[2, 4, 6]], + ), + ), + ) + + self.assertTrue( + (3 * monomial2).equals( + FDataBasis( + basis, + [[3, 6, 9], [9, 12, 15]], + ), + ), + ) + + self.assertTrue( + (3 * monomial2).equals( + monomial2 * 3, + ), + ) + + self.assertTrue( + (monomial2 * [1, 2]).equals( + FDataBasis( + basis, + [[1, 2, 3], [6, 8, 10]], + ), + ), + ) - self.assertTrue((monomial1 * 2).equals( - FDataBasis(Monomial(n_basis=3), - [[2, 4, 6]]))) - self.assertTrue((3 * monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[3, 6, 9], [9, 12, 15]]))) - self.assertTrue((3 * monomial2).equals( - monomial2 * 3)) - - self.assertTrue((monomial2 * [1, 2]).equals( - FDataBasis(Monomial(n_basis=3), - [[1, 2, 3], [6, 8, 10]]))) - self.assertTrue(([1, 2] * monomial2).equals( - FDataBasis(Monomial(n_basis=3), - [[1, 2, 3], [6, 8, 10]]))) + self.assertTrue( + ([1, 2] * monomial2).equals( + FDataBasis( + basis, + [[1, 2, 3], [6, 8, 10]], + ), + ), + ) with np.testing.assert_raises(TypeError): - monomial2 * FDataBasis(Fourier(n_basis=3), - [[2, 2, 3], [5, 4, 5]]) + monomial2 * FDataBasis( # noqa: WPS428 + Fourier(n_basis=3), + [[2, 2, 3], [5, 4, 5]], + ) with np.testing.assert_raises(TypeError): - monomial2 * monomial2 + monomial2 * monomial2 # noqa: WPS428 - def test_fdatabasis__div__(self): - monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) + def test_fdatabasis_div(self) -> None: + """Test division of FDataBasis.""" + basis = Monomial(n_basis=3) + + monomial1 = FDataBasis(basis, [1, 2, 3]) + monomial2 = FDataBasis(basis, [[1, 2, 3], [3, 4, 5]]) self.assertTrue((monomial1 / 2).equals( - FDataBasis(Monomial(n_basis=3), - [[1 / 2, 1, 3 / 2]]))) - self.assertTrue((monomial2 / 2).equals( - FDataBasis(Monomial(n_basis=3), - [[1 / 2, 1, 3 / 2], [3 / 2, 2, 5 / 2]]))) - - self.assertTrue((monomial2 / [1, 2]).equals( - FDataBasis(Monomial(n_basis=3), - [[1, 2, 3], [3 / 2, 2, 5 / 2]]))) - - def test_fdatabasis_derivative_constant(self): - constant = FDataBasis(Constant(), - [[1], [2], [3], [4]]) - - self.assertTrue(constant.derivative().equals( - FDataBasis(Constant(), - [[0], [0], [0], [0]]))) - self.assertTrue(constant.derivative(order=0).equals( - FDataBasis(Constant(), - [[1], [2], [3], [4]]))) - - def test_fdatabasis_derivative_monomial(self): - monomial = FDataBasis(Monomial(n_basis=8), - [1, 5, 8, 9, 7, 8, 4, 5]) - monomial2 = FDataBasis(Monomial(n_basis=5), - [[4, 9, 7, 4, 3], - [1, 7, 9, 8, 5], - [4, 6, 6, 6, 8]]) - - self.assertTrue(monomial.derivative().equals( - FDataBasis(Monomial(n_basis=7), - [5, 16, 27, 28, 40, 24, 35]))) - self.assertTrue(monomial.derivative(order=0).equals(monomial)) - self.assertTrue(monomial.derivative(order=6).equals( - FDataBasis(Monomial(n_basis=2), - [2880, 25200]))) - self.assertTrue(monomial2.derivative().equals( - FDataBasis(Monomial(n_basis=4), - [[9, 14, 12, 12], + FDataBasis( + basis, + [[1 / 2, 1, 3 / 2]], + ), + )) + + self.assertTrue( + (monomial2 / 2).equals( + FDataBasis( + basis, + [[1 / 2, 1, 3 / 2], [3 / 2, 2, 5 / 2]], + ), + ), + ) + + self.assertTrue( + (monomial2 / [1, 2]).equals( + FDataBasis( + basis, + [[1.0, 2.0, 3.0], [3 / 2, 2, 5 / 2]], + ), + ), + ) + + +class TestFDataBasisDerivatives(unittest.TestCase): + """Test FDataBasis derivatives.""" + + def test_fdatabasis_derivative_constant(self) -> None: + """Test derivatives with a constant basis.""" + constant = FDataBasis( + Constant(), + [[1], [2], [3], [4]], + ) + + self.assertTrue( + constant.derivative().equals( + FDataBasis( + Constant(), + [[0], [0], [0], [0]], + ), + ), + ) + + self.assertTrue( + constant.derivative(order=0).equals( + FDataBasis( + Constant(), + [[1], [2], [3], [4]], + ), + ), + ) + + def test_fdatabasis_derivative_monomial(self) -> None: + """Test derivatives with a monomial basis.""" + monomial = FDataBasis( + Monomial(n_basis=8), + [1, 5, 8, 9, 7, 8, 4, 5], + ) + + monomial2 = FDataBasis( + Monomial(n_basis=5), + [ + [4, 9, 7, 4, 3], + [1, 7, 9, 8, 5], + [4, 6, 6, 6, 8], + ], + ) + + self.assertTrue( + monomial.derivative().equals( + FDataBasis( + Monomial(n_basis=7), + [5, 16, 27, 28, 40, 24, 35], + ), + ), + ) + + self.assertTrue( + monomial.derivative(order=0).equals(monomial), + ) + + self.assertTrue( + monomial.derivative(order=6).equals( + FDataBasis( + Monomial(n_basis=2), + [2880, 25200], + ), + ), + ) + + self.assertTrue( + monomial2.derivative().equals( + FDataBasis( + Monomial(n_basis=4), + [ + [9, 14, 12, 12], [7, 18, 24, 20], - [6, 12, 18, 32]]))) - self.assertTrue(monomial2.derivative(order=0).equals(monomial2)) - self.assertTrue(monomial2.derivative(order=3).equals( - FDataBasis(Monomial(n_basis=2), - [[24, 72], + [6, 12, 18, 32], + ], + ), + ), + ) + + self.assertTrue( + monomial2.derivative(order=0).equals(monomial2), + ) + + self.assertTrue( + monomial2.derivative(order=3).equals( + FDataBasis( + Monomial(n_basis=2), + [ + [24, 72], [48, 120], - [36, 192]]))) + [36, 192], + ], + ), + ), + ) + + def test_fdatabasis_derivative_fourier(self) -> None: + """Test derivatives with a fourier basis.""" + fourier = FDataBasis( + Fourier(n_basis=7), + [1, 5, 8, 9, 8, 4, 5], + ) - def test_fdatabasis_derivative_fourier(self): - fourier = FDataBasis(Fourier(n_basis=7), - [1, 5, 8, 9, 8, 4, 5]) - fourier2 = FDataBasis(Fourier(n_basis=5), - [[4, 9, 7, 4, 3], - [1, 7, 9, 8, 5], - [4, 6, 6, 6, 8]]) + fourier2 = FDataBasis( + Fourier(n_basis=5), + [ + [4, 9, 7, 4, 3], + [1, 7, 9, 8, 5], + [4, 6, 6, 6, 8], + ], + ) fou0 = fourier.derivative(order=0) fou1 = fourier.derivative() @@ -311,16 +501,25 @@ def test_fdatabasis_derivative_fourier(self): np.testing.assert_equal(fou1.basis, fourier.basis) np.testing.assert_almost_equal( fou1.coefficients.round(5), - np.atleast_2d([0, -50.26548, 31.41593, - -100.53096, 113.09734, - -94.24778, 75.39822])) + np.atleast_2d( + [ # noqa: WPS317 + 0, -50.26548, 31.41593, -100.53096, + 113.09734, -94.24778, 75.39822, + ], + ), + ) + self.assertTrue(fou0.equals(fourier)) np.testing.assert_equal(fou2.basis, fourier.basis) np.testing.assert_almost_equal( fou2.coefficients.round(5), - np.atleast_2d([0, -197.39209, -315.82734, - -1421.22303, -1263.30936, - -1421.22303, -1776.52879])) + np.atleast_2d( + [ # noqa: WPS317 + 0, -197.39209, -315.82734, -1421.22303, + -1263.30936, -1421.22303, -1776.52879, + ], + ), + ) fou0 = fourier2.derivative(order=0) fou1 = fourier2.derivative() @@ -329,77 +528,106 @@ def test_fdatabasis_derivative_fourier(self): np.testing.assert_equal(fou1.basis, fourier2.basis) np.testing.assert_almost_equal( fou1.coefficients.round(5), - [[0, -43.98230, 56.54867, -37.69911, 50.26548], - [0, -56.54867, 43.98230, - - 62.83185, 100.53096], - [0, -37.69911, 37.69911, -100.53096, 75.39822]]) + [ + [0, -43.9823, 56.54867, -37.69911, 50.26548], + [0, -56.54867, 43.9823, -62.83185, 100.53096], + [0, -37.69911, 37.69911, -100.53096, 75.39822], + ], + ) + self.assertTrue(fou0.equals(fourier2)) np.testing.assert_equal(fou2.basis, fourier2.basis) np.testing.assert_almost_equal( fou2.coefficients.round(5), - [[0, -355.30576, -276.34892, -631.65468, -473.74101], - [0, -276.34892, -355.30576, - - 1263.30936, -789.56835], - [0, -236.87051, -236.87051, -947.48202, -1263.30936]]) - - def test_fdatabasis_derivative_bspline(self): - bspline = FDataBasis(BSpline(n_basis=8), - [1, 5, 8, 9, 7, 8, 4, 5]) - bspline2 = FDataBasis(BSpline(n_basis=5), - [[4, 9, 7, 4, 3], - [1, 7, 9, 8, 5], - [4, 6, 6, 6, 8]]) + [ + [0, -355.30576, -276.34892, -631.65468, -473.74101], + [0, -276.34892, -355.30576, -1263.30936, -789.56835], + [0, -236.87051, -236.87051, -947.48202, -1263.30936], + ], + ) + + def test_fdatabasis_derivative_bspline(self) -> None: + """Test derivatives with a B-spline basis.""" + bspline = FDataBasis( + BSpline(n_basis=8), + [1, 5, 8, 9, 7, 8, 4, 5], + ) + bspline2 = FDataBasis( + BSpline(n_basis=5), + [ + [4, 9, 7, 4, 3], + [1, 7, 9, 8, 5], + [4, 6, 6, 6, 8], + ], + ) bs0 = bspline.derivative(order=0) bs1 = bspline.derivative() bs2 = bspline.derivative(order=2) np.testing.assert_equal(bs1.basis, BSpline(n_basis=7, order=3)) - np.testing.assert_almost_equal(bs1.coefficients, - np.atleast_2d([60, 22.5, 5, - -10, 5, -30, 15])) + + np.testing.assert_almost_equal( + bs1.coefficients, + np.atleast_2d([60, 22.5, 5, -10, 5, -30, 15]), + ) + self.assertTrue(bs0.equals(bspline)) - np.testing.assert_equal(bs2.basis, BSpline(n_basis=6, order=2)) - np.testing.assert_almost_equal(bs2.coefficients, - np.atleast_2d([-375, -87.5, -75, - 75, -175, 450])) + + np.testing.assert_equal( + bs2.basis, + BSpline(n_basis=6, order=2), + ) + + np.testing.assert_almost_equal( + bs2.coefficients, + np.atleast_2d([-375, -87.5, -75, 75, -175, 450]), + ) bs0 = bspline2.derivative(order=0) bs1 = bspline2.derivative() bs2 = bspline2.derivative(order=2) np.testing.assert_equal(bs1.basis, BSpline(n_basis=4, order=3)) - np.testing.assert_almost_equal(bs1.coefficients, - [[30, -6, -9, -6], - [36, 6, -3, -18], - [12, 0, 0, 12]]) + + np.testing.assert_almost_equal( + bs1.coefficients, + [ + [30, -6, -9, -6], + [36, 6, -3, -18], + [12, 0, 0, 12], + ], + ) + self.assertTrue(bs0.equals(bspline2)) - np.testing.assert_equal(bs2.basis, BSpline(n_basis=3, order=2)) - np.testing.assert_almost_equal(bs2.coefficients, - [[-144, -6, 12], - [-120, -18, -60], - [-48, 0, 48]]) - def test_concatenate(self): - sample1 = np.arange(0, 10) - sample2 = np.arange(10, 20) - fd1 = FDataGrid([sample1]).to_basis(Fourier(n_basis=5)) - fd2 = FDataGrid([sample2]).to_basis(Fourier(n_basis=5)) + np.testing.assert_equal( + bs2.basis, + BSpline(n_basis=3, order=2), + ) - fd = concatenate([fd1, fd2]) + np.testing.assert_almost_equal( + bs2.coefficients, + [ + [-144, -6, 12], + [-120, -18, -60], + [-48, 0, 48], + ], + ) - np.testing.assert_equal(fd.n_samples, 2) - np.testing.assert_equal(fd.dim_codomain, 1) - np.testing.assert_equal(fd.dim_domain, 1) - np.testing.assert_array_equal(fd.coefficients, np.concatenate( - [fd1.coefficients, fd2.coefficients])) - def test_vector_valued(self): +class TestVectorValuedBasis(unittest.TestCase): + """Tests for the vector valued basis.""" + + def test_vector_valued(self) -> None: + """Test vector valued basis.""" X, _ = skfda.datasets.fetch_weather(return_X_y=True) basis_dim = skfda.representation.basis.Fourier( - n_basis=7, domain_range=X.domain_range) + n_basis=7, + domain_range=X.domain_range, + ) basis = skfda.representation.basis.VectorValued( - [basis_dim] * 2 + [basis_dim] * 2, ) X_basis = X.to_basis(basis) @@ -409,12 +637,14 @@ def test_vector_valued(self): self.assertEqual(X_basis.coordinates[0].basis, basis_dim) np.testing.assert_allclose( X_basis.coordinates[0].coefficients, - X.coordinates[0].to_basis(basis_dim).coefficients) + X.coordinates[0].to_basis(basis_dim).coefficients, + ) self.assertEqual(X_basis.coordinates[1].basis, basis_dim) np.testing.assert_allclose( X_basis.coordinates[1].coefficients, - X.coordinates[1].to_basis(basis_dim).coefficients) + X.coordinates[1].to_basis(basis_dim).coefficients, + ) class TestTensorBasis(unittest.TestCase): @@ -520,5 +750,4 @@ def test_tensor_gram_matrix(self) -> None: if __name__ == '__main__': - print() unittest.main() diff --git a/tests/test_clustering.py b/tests/test_clustering.py index b29c06d2b..66f2024f0 100644 --- a/tests/test_clustering.py +++ b/tests/test_clustering.py @@ -1,18 +1,22 @@ -from skfda.ml.clustering import KMeans, FuzzyCMeans -from skfda.representation.grid import FDataGrid import unittest import numpy as np +from skfda.ml.clustering import FuzzyCMeans, KMeans +from skfda.representation.grid import FDataGrid + class TestClustering(unittest.TestCase): # def setUp(self): could be defined for set up before any test - def test_kmeans_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - [-1, -1, -0.5, 1, 1, 0.5], - [-0.5, -0.5, -0.5, -1, -1, -1]] + def test_kmeans_univariate(self) -> None: + data_matrix = [ + [1, 1, 2, 3, 2.5, 2], + [0.5, 0.5, 1, 2, 1.5, 1], + [-1, -1, -0.5, 1, 1, 0.5], + [-0.5, -0.5, -0.5, -1, -1, -1], + ] grid_points = [0, 2, 4, 6, 8, 10] fd = FDataGrid(data_matrix, grid_points) init = np.array([[0, 0, 0, 0, 0, 0], [2, 1, -1, 0.5, 0, -0.5]]) @@ -28,113 +32,68 @@ def test_kmeans_univariate(self): [6.49679408, 0.0], ]), ) - np.testing.assert_array_equal(kmeans.predict(fd), - np.array([0, 0, 0, 1])) - np.testing.assert_allclose(kmeans.transform(fd), - np.array([[2.98142397, 9.23534876], - [0.68718427, 6.50960828], - [3.31243449, 4.39222798], - [6.49679408, 0.]])) - centers = FDataGrid(data_matrix=np.array( - [[0.16666667, 0.16666667, 0.83333333, 2., 1.66666667, 1.16666667], - [-0.5, -0.5, -0.5, -1., -1., -1.]]), + np.testing.assert_array_equal( + kmeans.predict(fd), + np.array([0, 0, 0, 1]), + ) + np.testing.assert_allclose( + kmeans.transform(fd), + np.array([[2.98142397, 9.23534876], + [0.68718427, 6.50960828], + [3.31243449, 4.39222798], + [6.49679408, 0.]]), + ) + centers = FDataGrid( + data_matrix=np.array([ + [0.16666667, 0.16666667, 0.83333333, 2., 1.66666667, 1.16666667], + [-0.5, -0.5, -0.5, -1., -1., -1.], + ]), grid_points=grid_points) np.testing.assert_array_almost_equal( kmeans.cluster_centers_.data_matrix, - centers.data_matrix) + centers.data_matrix, + ) np.testing.assert_allclose(kmeans.score(fd), np.array([-20.33333333])) np.testing.assert_array_equal(kmeans.n_iter_, np.array([3.])) - # def test_kmeans_multivariate(self): - # data_matrix = [[[1, 0.3], [2, 0.4], [3, 0.5], [4, 0.6]], - # [[2, 0.5], [3, 0.6], [4, 0.7], [5, 0.7]], - # [[3, 0.2], [4, 0.3], [5, 0.4], [6, 0.5]]] - # grid_points = [2, 4, 6, 8] - # fd = FDataGrid(data_matrix, grid_points) - # kmeans = KMeans() - # kmeans.fit(fd) - # np.testing.assert_array_equal(kmeans.predict(fd), - # np.array([[1, 1], - # [1, 1], - # [0, 0]])) - # np.testing.assert_allclose(kmeans.transform(fd), - # np.array([[[4.89897949, 0.24494897], - # [1.22474487, 0.23184046]], - # [[2.44948974, 0.70592729], - # [1.22474487, 0.23184046]], - # [[0., 0.], - # [3.67423461, 0.47478065]]])) - # centers = FDataGrid(data_matrix=np.array( - # [[[3, 0.2], [4, 0.3], [5, 0.4], [6, 0.5]], - # [[1.5, 0.4], [2.5, 0.5], [3.5, 0.6], [4.5, 0.65]]]), - # grid_points=grid_points) - # np.testing.assert_allclose(kmeans.cluster_centers_.data_matrix, - # centers.data_matrix) - # np.testing.assert_allclose(kmeans.score(fd), np.array([-3., -0.1075])) - # np.testing.assert_array_equal(kmeans.n_iter_, np.array([2., 2.])) - - def test_fuzzy_kmeans_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - [-1, -1, -0.5, 1, 1, 0.5], - [-0.5, -0.5, -0.5, -1, -1, -1]] + def test_fuzzy_kmeans_univariate(self) -> None: + data_matrix = [ + [1, 1, 2, 3, 2.5, 2], + [0.5, 0.5, 1, 2, 1.5, 1], + [-1, -1, -0.5, 1, 1, 0.5], + [-0.5, -0.5, -0.5, -1, -1, -1], + ] grid_points = [0, 2, 4, 6, 8, 10] fd = FDataGrid(data_matrix, grid_points) fuzzy_kmeans = FuzzyCMeans() fuzzy_kmeans.fit(fd) - np.testing.assert_array_equal(fuzzy_kmeans.predict(fd).round(3), - np.array([[0.965, 0.035], - [0.94, 0.06], - [0.227, 0.773], - [0.049, 0.951]])) - np.testing.assert_allclose(fuzzy_kmeans.transform(fd).round(3), - np.array([[1.492, 7.879], - [1.294, 5.127], - [4.856, 2.633], - [7.775, 1.759]])) - centers = np.array([[0.707, 0.707, 1.455, 2.467, 1.981, 1.482], - [-0.695, -0.695, -0.494, -0.197, -0.199, -0.398]]) + np.testing.assert_array_equal( + fuzzy_kmeans.predict_proba(fd).round(3), + np.array([[0.965, 0.035], + [0.94, 0.06], + [0.227, 0.773], + [0.049, 0.951]]), + ) + np.testing.assert_allclose( + fuzzy_kmeans.transform(fd).round(3), + np.array([[1.492, 7.879], + [1.294, 5.127], + [4.856, 2.633], + [7.775, 1.759]]), + ) + centers = np.array([ + [0.707, 0.707, 1.455, 2.467, 1.981, 1.482], + [-0.695, -0.695, -0.494, -0.197, -0.199, -0.398], + ]) np.testing.assert_allclose( fuzzy_kmeans.cluster_centers_.data_matrix[..., 0].round(3), centers) - np.testing.assert_allclose(fuzzy_kmeans.score(fd), - np.array([-12.025179])) + np.testing.assert_allclose( + fuzzy_kmeans.score(fd), + np.array([-12.025179]), + ) self.assertEqual(fuzzy_kmeans.n_iter_, 19) - # def test_fuzzy_kmeans_multivariate(self): - # data_matrix = [[[1, 0.3], [2, 0.4], [3, 0.5], [4, 0.6]], - # [[2, 0.5], [3, 0.6], [4, 0.7], [5, 0.7]], - # [[3, 0.2], [4, 0.3], [5, 0.4], [6, 0.5]]] - # grid_points = [2, 4, 6, 8] - # fd = FDataGrid(data_matrix, grid_points) - # init = np.array([[[3, 0], [5, 0], [2, 0], [4, 0]], - # [[0, 0], [0, 1], [0, 0], [0, 1]]]) - # init_fd = FDataGrid(init, grid_points) - # fuzzy_kmeans = FuzzyKMeans(init=init_fd) - # fuzzy_kmeans.fit(fd) - # np.testing.assert_array_equal(fuzzy_kmeans.predict(fd), - # np.array([[[0., 1.], - # [0.5, 0.5]], - # [[1., 0.], - # [0.5, 0.5]], - # [[0.8, 0.2], - # [0.5, 0.5]]])) - # np.testing.assert_allclose(fuzzy_kmeans.transform(fd), - # np.array([[[25., 1.26333333], - # [126.33333333, 1.26333333]], - # [[25., 2.45833333], - # [126.33333333, 2.45833333]], - # [[6., 0.78333333], - # [24., 0.78333333]]])) - # centers = FDataGrid(data_matrix=np.array( - # [[[2, 0], [3, 0], [4, 0], [5, 0]], - # [[1, 0], [2, 0], [3, 0], [4, 0]]]), grid_points=grid_points) - # np.testing.assert_allclose(fuzzy_kmeans.cluster_centers_.data_matrix, - # centers.data_matrix) - # np.testing.assert_allclose(fuzzy_kmeans.score(fd), np.array( - # [-1.66211111e+04, -8.25302500e+00])) - # np.testing.assert_array_equal(fuzzy_kmeans.n_iter_, - # np.array([2., 2.])) - if __name__ == '__main__': print() diff --git a/tests/test_elastic.py b/tests/test_elastic.py index 1671cd5a4..47ea176c5 100644 --- a/tests/test_elastic.py +++ b/tests/test_elastic.py @@ -1,79 +1,103 @@ -from skfda import FDataGrid -from skfda.datasets import make_multimodal_samples, make_random_warping -from skfda.misc.metrics import (fisher_rao_distance, amplitude_distance, - phase_distance, pairwise_distance, lp_distance, - warping_distance) -from skfda.preprocessing.registration import (ElasticRegistration, - invert_warping, - normalize_warping) -from skfda.preprocessing.registration.elastic import (SRSF, elastic_mean, - warping_mean) +"""Tests for elastic registration and functions in the SRVF framework.""" + import unittest import numpy as np - -metric = pairwise_distance(lp_distance) -pairwise_fisher_rao = pairwise_distance(fisher_rao_distance) +from skfda import FDataGrid +from skfda.datasets import make_multimodal_samples, make_random_warping +from skfda.misc.metrics import ( + PairwiseMetric, + amplitude_distance, + fisher_rao_distance, + l2_distance, + phase_distance, + warping_distance, +) +from skfda.preprocessing.registration import ( + ElasticRegistration, + invert_warping, + normalize_warping, +) +from skfda.preprocessing.registration.elastic import ( + SRSF, + elastic_mean, + warping_mean, +) + +metric = PairwiseMetric(l2_distance) +pairwise_fisher_rao = PairwiseMetric(fisher_rao_distance) class TestElasticRegistration(unittest.TestCase): - """Test elastic registration""" + """Test elastic registration.""" - def setUp(self): - """Initialization of samples""" + def setUp(self) -> None: + """Initialize the samples.""" template = make_multimodal_samples(n_samples=1, std=0, random_state=1) self.template = template self.template_rep = template.concatenate( - template).concatenate(template) - self.unimodal_samples = make_multimodal_samples(n_samples=3, - random_state=1) + template, + ).concatenate(template) + self.unimodal_samples = make_multimodal_samples( + n_samples=3, + random_state=1, + ) t = np.linspace(-3, 3, 9) self.dummy_sample = FDataGrid([np.sin(t)], t) - def test_to_srsf(self): - """Test to srsf""" + def test_to_srsf(self) -> None: + """Test to srsf.""" # Checks SRSF conversion - srsf = SRSF().fit_transform(self.dummy_sample) - data_matrix = [[[-1.061897], [-0.75559027], [0.25355399], - [0.81547327], [0.95333713], [0.81547327], - [0.25355399], [-0.75559027], [-1.06189697]]] + data_matrix = [ + [ # noqa: WPS317 + [-1.061897], [-0.75559027], [0.25355399], + [0.81547327], [0.95333713], [0.81547327], + [0.25355399], [-0.75559027], [-1.06189697], + ], + ] np.testing.assert_almost_equal(data_matrix, srsf.data_matrix) - def test_from_srsf(self): - """Test from srsf""" - + def test_from_srsf(self) -> None: + """Test from srsf.""" # Checks SRSF conversion srsf = SRSF(initial_value=0).inverse_transform(self.dummy_sample) - data_matrix = [[[0.], [-0.23449228], [-0.83464009], - [-1.38200046], [-1.55623723], [-1.38200046], - [-0.83464009], [-0.23449228], [0.]]] + data_matrix = [ + [ # noqa: WPS317 + [0], [-0.23449228], [-0.83464009], + [-1.38200046], [-1.55623723], [-1.38200046], + [-0.83464009], [-0.23449228], [0], + ], + ] np.testing.assert_almost_equal(data_matrix, srsf.data_matrix) - def test_from_srsf_with_output_points(self): - """Test from srsf""" - + def test_from_srsf_with_output_points(self) -> None: + """Test from srsf.""" # Checks SRSF conversion srsf_transformer = SRSF( initial_value=0, - output_points=self.dummy_sample.grid_points[0]) + output_points=self.dummy_sample.grid_points[0], + ) srsf = srsf_transformer.inverse_transform(self.dummy_sample) - data_matrix = [[[0.], [-0.23449228], [-0.83464009], - [-1.38200046], [-1.55623723], [-1.38200046], - [-0.83464009], [-0.23449228], [0.]]] + data_matrix = [ + [ # noqa: WPS317 + [0], [-0.23449228], [-0.83464009], + [-1.38200046], [-1.55623723], [-1.38200046], + [-0.83464009], [-0.23449228], [0], + ], + ] np.testing.assert_almost_equal(data_matrix, srsf.data_matrix) - def test_srsf_conversion(self): - """Converts to srsf and pull backs""" - + def test_srsf_conversion(self) -> None: + """Converts to srsf and pull backs.""" srsf = SRSF() converted = srsf.fit_transform(self.unimodal_samples) @@ -84,24 +108,24 @@ def test_srsf_conversion(self): np.testing.assert_allclose(distances, 0, atol=8e-3) - def test_template_alignment(self): - """Test alignment to 1 template""" + def test_template_alignment(self) -> None: + """Test alignment to 1 template.""" reg = ElasticRegistration(template=self.template) register = reg.fit_transform(self.unimodal_samples) distances = metric(self.template, register) np.testing.assert_allclose(distances, 0, atol=12e-3) - def test_one_to_one_alignment(self): - """Test alignment to 1 sample to a template""" + def test_one_to_one_alignment(self) -> None: + """Test alignment to 1 sample to a template.""" reg = ElasticRegistration(template=self.template) register = reg.fit_transform(self.unimodal_samples[0]) distances = metric(self.template, register) np.testing.assert_allclose(distances, 0, atol=12e-3) - def test_set_alignment(self): - """Test alignment 3 curves to set with 3 templates""" + def test_set_alignment(self) -> None: + """Test alignment 3 curves to set with 3 templates.""" # Should give same result than test_template_alignment reg = ElasticRegistration(template=self.template_rep) register = reg.fit_transform(self.unimodal_samples) @@ -109,41 +133,51 @@ def test_set_alignment(self): np.testing.assert_allclose(distances, 0, atol=12e-3) - def test_default_alignment(self): - """Test alignment by default""" + def test_default_alignment(self) -> None: + """Test alignment by default.""" # Should give same result than test_template_alignment reg = ElasticRegistration() register = reg.fit_transform(self.unimodal_samples) - values = register([-.25, -.1, 0, .1, .25]) + values = register([-0.25, -0.1, 0, 0.1, 0.25]) - expected = [[[0.599058], [0.997427], [0.772248], - [0.412342], [0.064725]], - [[0.626875], [0.997155], [0.791649], - [0.382181], [0.050098]], - [[0.620992], [0.997369], [0.785886], - [0.376556], [0.048804]]] + expected = [ + [ + [0.599058], [0.997427], [0.772248], [0.412342], [0.064725], + ], + [ + [0.626875], [0.997155], [0.791649], [0.382181], [0.050098], + ], + [ + [0.620992], [0.997369], [0.785886], [0.376556], [0.048804], + ], + ] np.testing.assert_allclose(values, expected, atol=1e-4) - def test_callable_alignment(self): - """Test alignment by default""" + def test_callable_alignment(self) -> None: + """Test alignment by default.""" # Should give same result than test_template_alignment reg = ElasticRegistration(template=elastic_mean) register = reg.fit_transform(self.unimodal_samples) - values = register([-.25, -.1, 0, .1, .25]) - expected = [[[0.599058], [0.997427], [0.772248], - [0.412342], [0.064725]], - [[0.626875], [0.997155], [0.791649], - [0.382181], [0.050098]], - [[0.620992], [0.997369], [0.785886], - [0.376556], [0.048804]]] + values = register([-0.25, -0.1, 0, 0.1, 0.25]) + expected = [ + [ + [0.599058], [0.997427], [0.772248], [0.412342], [0.064725], + ], + [ + [0.626875], [0.997155], [0.791649], [0.382181], [0.050098], + ], + [ + [0.620992], [0.997369], [0.785886], [0.376556], [0.048804], + ], + ] np.testing.assert_allclose(values, expected, atol=1e-4) - def test_simmetry_of_aligment(self): - """Check registration using inverse composition""" + def test_simmetry_of_aligment(self) -> None: + """Check registration using inverse composition.""" reg = ElasticRegistration(template=self.template) reg.fit_transform(self.unimodal_samples) warping = reg.warping_ @@ -153,13 +187,10 @@ def test_simmetry_of_aligment(self): np.testing.assert_allclose(distances, 0, atol=12e-3) - def test_raises(self): + def test_raises(self) -> None: + """Test that the assertions raise when appropriate.""" reg = ElasticRegistration() - # X not in fit, but template is not an FDataGrid - with np.testing.assert_raises(ValueError): - reg.fit() - # Inverse transform without previous transform with np.testing.assert_raises(ValueError): reg.inverse_transform(self.unimodal_samples) @@ -170,31 +201,33 @@ def test_raises(self): reg.inverse_transform(self.unimodal_samples[0]) # FDataGrid as template with n != 1 and n!= n_samples to transform - reg = ElasticRegistration(template=self.unimodal_samples).fit() + reg = ElasticRegistration(template=self.unimodal_samples).fit( + self.unimodal_samples[0], + ) with np.testing.assert_raises(ValueError): reg.transform(self.unimodal_samples[0]) - def test_score(self): - """Test score method of the transformer""" + def test_score(self) -> None: + """Test score method of the transformer.""" reg = ElasticRegistration() reg.fit(self.unimodal_samples) score = reg.score(self.unimodal_samples) - np.testing.assert_almost_equal(score, 0.9994225) + np.testing.assert_almost_equal(score, 0.999389) - def test_warping_mean(self): + def test_warping_mean(self) -> None: + """Test the warping_mean function.""" warping = make_random_warping(start=-1, random_state=0) mean = warping_mean(warping) - values = mean([-1, -.5, 0, .5, 1]) - expected = [[[-1.], [-0.376241], [0.136193], [0.599291], [1.]]] + values = mean([-1, -0.5, 0, 0.5, 1]) + expected = [[[-1], [-0.376241], [0.136193], [0.599291], [1]]] np.testing.assert_array_almost_equal(values, expected) class TestElasticDistances(unittest.TestCase): - """Test elastic distances""" - - def test_fisher_rao(self): - """Test fisher rao distance""" + """Test elastic distances.""" + def test_fisher_rao(self) -> None: + """Test fisher rao distance.""" t = np.linspace(0, 1, 100) sample = FDataGrid([t, 1 - t], t) f = np.square(sample) @@ -205,9 +238,8 @@ def test_fisher_rao(self): np.testing.assert_almost_equal(res, distance, decimal=3) - def test_fisher_rao_invariance(self): - """Test invariance of fisher rao metric: d(f,g)= d(foh, goh)""" - + def test_fisher_rao_invariance(self) -> None: + """Test invariance of fisher rao metric: d(f,g)= d(foh, goh).""" t = np.linspace(0, np.pi, 1000) id = FDataGrid([t], t) cos = np.cos(id) @@ -218,21 +250,30 @@ def test_fisher_rao_invariance(self): distance_original = fisher_rao_distance(cos, sin) # Construction of 2 warpings - distance_warping = fisher_rao_distance(cos.compose(gamma), - sin.compose(gamma)) - distance_warping2 = fisher_rao_distance(cos.compose(gamma2), - sin.compose(gamma2)) + distance_warping = fisher_rao_distance( + cos.compose(gamma), + sin.compose(gamma), + ) + distance_warping2 = fisher_rao_distance( + cos.compose(gamma2), + sin.compose(gamma2), + ) # The error ~0.001 due to the derivation - np.testing.assert_allclose(distance_original, distance_warping, - atol=0.01) - - np.testing.assert_allclose(distance_original, distance_warping2, - atol=0.01) - - def test_amplitude_distance_limit(self): - """Test limit of amplitude distance penalty""" - + np.testing.assert_allclose( + distance_original, + distance_warping, + atol=0.01, + ) + + np.testing.assert_allclose( + distance_original, + distance_warping2, + atol=0.01, + ) + + def test_amplitude_distance_limit(self) -> None: + """Test limit of amplitude distance penalty.""" f = make_multimodal_samples(n_samples=1, random_state=1) g = make_multimodal_samples(n_samples=1, random_state=9999) @@ -241,16 +282,16 @@ def test_amplitude_distance_limit(self): np.testing.assert_almost_equal(amplitude_limit, fr_distance) - def test_phase_distance_id(self): - """Test of phase distance invariance""" + def test_phase_distance_id(self) -> None: + """Test of phase distance invariance.""" f = make_multimodal_samples(n_samples=1, random_state=1) phase = phase_distance(f, 2 * f) np.testing.assert_allclose(phase, 0, atol=1e-7) - def test_warping_distance(self): - """Test of warping distance""" + def test_warping_distance(self) -> None: + """Test of warping distance.""" t = np.linspace(0, 1, 1000) w1 = FDataGrid([t**5], t) w2 = FDataGrid([t**3], t) diff --git a/tests/test_fdatagrid_numpy.py b/tests/test_fdatagrid_numpy.py index e58d5396d..ef3455844 100644 --- a/tests/test_fdatagrid_numpy.py +++ b/tests/test_fdatagrid_numpy.py @@ -1,47 +1,73 @@ -from skfda import FDataGrid +"""Tests of compatibility between numpy ufuncs and FDataGrid.""" + import unittest +from typing import Any, Callable, TypeVar + import numpy as np +import pytest + +from skfda import FDataGrid + + +@pytest.fixture(params=[ + np.sqrt, + np.absolute, + np.round, + np.exp, + np.log, + np.log10, + np.log2, +]) +def monary(request: Any) -> Any: + """ + Fixture providing the monary function to validate. + + Not all of them are ufuncs. + + """ + return request.param + +T = TypeVar("T", np.ndarray, FDataGrid) -class TestFDataGridNumpy(unittest.TestCase): - def test_monary_ufunc(self): - data_matrix = np.arange(15).reshape(3, 5) +def test_monary_ufuncs(monary: Callable[[T], T]) -> None: + """Test that unary ufuncs can be applied to FDataGrid.""" + data_matrix = np.arange(15).reshape(3, 5) + 1 - fd = FDataGrid(data_matrix) + fd = FDataGrid(data_matrix) - fd_sqrt = np.sqrt(fd) + fd_monary = monary(fd) - fd_sqrt_build = FDataGrid(np.sqrt(data_matrix)) + fd_monary_build = FDataGrid(monary(data_matrix)) - self.assertTrue(fd_sqrt.equals(fd_sqrt_build)) + assert fd_monary.equals(fd_monary_build) - def test_binary_ufunc(self): - data_matrix = np.arange(15).reshape(3, 5) - data_matrix2 = 2 * np.arange(15).reshape(3, 5) - fd = FDataGrid(data_matrix) - fd2 = FDataGrid(data_matrix2) +def test_binary_ufunc() -> None: + """Test that binary ufuncs can be applied to FDataGrid.""" + data_matrix = np.arange(15).reshape(3, 5) + data_matrix2 = 2 * np.arange(15).reshape(3, 5) - fd_mul = np.multiply(fd, fd2) + fd = FDataGrid(data_matrix) + fd2 = FDataGrid(data_matrix2) - fd_mul_build = FDataGrid(data_matrix * data_matrix2) + fd_mul = np.multiply(fd, fd2) - self.assertTrue(fd_mul.equals(fd_mul_build)) + fd_mul_build = FDataGrid(data_matrix * data_matrix2) - def test_out_ufunc(self): - data_matrix = np.arange(15.).reshape(3, 5) - data_matrix_copy = np.copy(data_matrix) + assert fd_mul.equals(fd_mul_build) - fd = FDataGrid(data_matrix) - np.sqrt(fd, out=fd) +def test_out_ufunc(monary: Callable[..., Any]) -> None: + """Test that the out parameter of ufuncs work for FDataGrid.""" + data_matrix = np.arange(15).reshape(3, 5) + 1 + data_matrix_copy = np.copy(data_matrix) - fd_sqrt_build = FDataGrid(np.sqrt(data_matrix_copy)) + fd = FDataGrid(data_matrix) - self.assertTrue(fd.equals(fd_sqrt_build)) + monary(fd, out=fd) + fd_monary_build = FDataGrid(monary(data_matrix_copy)) -if __name__ == '__main__': - print() - unittest.main() + assert fd.equals(fd_monary_build) diff --git a/tests/test_fpca.py b/tests/test_fpca.py index a5d69b287..ef0db4fcb 100644 --- a/tests/test_fpca.py +++ b/tests/test_fpca.py @@ -1,188 +1,220 @@ -from skfda import FDataGrid, FDataBasis +"""Tests for FPCA.""" +import unittest + +import numpy as np + +from skfda import FDataBasis, FDataGrid from skfda.datasets import fetch_weather from skfda.misc.operators import LinearDifferentialOperator from skfda.misc.regularization import TikhonovRegularization -from skfda.preprocessing.dim_reduction.projection import FPCA +from skfda.preprocessing.dim_reduction.feature_extraction import FPCA from skfda.representation.basis import Fourier -import unittest - -import numpy as np class FPCATestCase(unittest.TestCase): + """Tests for principal component analysis.""" - def test_basis_fpca_fit_attributes(self): + def test_basis_fpca_fit_exceptions(self) -> None: + """Check that invalid arguments in fit raise exception for basis.""" fpca = FPCA() with self.assertRaises(AttributeError): - fpca.fit(None) + fpca.fit(None) # type: ignore basis = Fourier(n_basis=1) - # check that if n_components is bigger than the number of samples then + # Check that if n_components is bigger than the number of samples then # an exception should be thrown fd = FDataBasis(basis, [[0.9]]) with self.assertRaises(AttributeError): fpca.fit(fd) - # check that n_components must be smaller than the number of elements + # Check that n_components must be smaller than the number of elements # of target basis fd = FDataBasis(basis, [[0.9], [0.7], [0.5]]) with self.assertRaises(AttributeError): fpca.fit(fd) - def test_discretized_fpca_fit_attributes(self): + def test_discretized_fpca_fit_exceptions(self) -> None: + """Check that invalid arguments in fit raise exception for grid.""" fpca = FPCA() with self.assertRaises(AttributeError): - fpca.fit(None) + fpca.fit(None) # type: ignore - # check that if n_components is bigger than the number of samples then + # Check that if n_components is bigger than the number of samples then # an exception should be thrown fd = FDataGrid([[0.5], [0.1]], grid_points=[0]) with self.assertRaises(AttributeError): fpca.fit(fd) - # check that n_components must be smaller than the number of attributes + # Check that n_components must be smaller than the number of attributes # in the FDataGrid object fd = FDataGrid([[0.9], [0.7], [0.5]], grid_points=[0]) with self.assertRaises(AttributeError): fpca.fit(fd) - def test_basis_fpca_fit_result(self): - + def test_basis_fpca_fit_result(self) -> None: + """Compare the components in basis against the fda package.""" n_basis = 9 n_components = 3 fd_data = fetch_weather()['data'].coordinates[0] - fd_data = FDataGrid(np.squeeze(fd_data.data_matrix), - np.arange(0.5, 365, 1)) - # initialize basis data + # Initialize basis data basis = Fourier(n_basis=n_basis, domain_range=(0, 365)) fd_basis = fd_data.to_basis(basis) - fpca = FPCA(n_components=n_components, - regularization=TikhonovRegularization( - LinearDifferentialOperator(2), - regularization_parameter=1e5)) + fpca = FPCA( + n_components=n_components, + regularization=TikhonovRegularization( + LinearDifferentialOperator(2), + regularization_parameter=1e5, + ), + ) fpca.fit(fd_basis) - # results obtained using Ramsay's R package - results = [[0.92407552, 0.13544888, 0.35399023, 0.00805966, - -0.02148108, - -0.01709549, -0.00208469, -0.00297439, -0.00308224], - [-0.33314436, -0.05116842, 0.89443418, 0.14673902, - 0.21559073, - 0.02046924, 0.02203431, -0.00787185, 0.00247492], - [-0.14241092, 0.92131899, 0.00514715, 0.23391411, - -0.19497613, - 0.09800817, 0.01754439, -0.00205874, 0.01438185]] - results = np.array(results) - - # compare results obtained using this library. There are slight + # Results obtained using Ramsay's R package + results = np.array([ + [ # noqa: WPS317 + 0.92407552, 0.13544888, 0.35399023, + 0.00805966, -0.02148108, -0.01709549, + -0.00208469, -0.00297439, -0.00308224, + ], + [ # noqa: WPS317 + -0.33314436, -0.05116842, 0.89443418, + 0.14673902, 0.21559073, 0.02046924, + 0.02203431, -0.00787185, 0.00247492, + ], + [ # noqa: WPS317 + -0.14241092, 0.92131899, 0.00514715, + 0.23391411, -0.19497613, 0.09800817, + 0.01754439, -0.00205874, 0.01438185, + ], + ]) + + # Compare results obtained using this library. There are slight # variations due to the fact that we are in two different packages - for i in range(n_components): - if np.sign(fpca.components_.coefficients[i][0]) != np.sign( - results[i][0]): - results[i, :] *= -1 - np.testing.assert_allclose(fpca.components_.coefficients, results, - atol=1e-7) + # If the sign of the components is not the same the component is + # reflected. + results *= ( + np.sign(fpca.components_.coefficients[:, 0]) + * np.sign(results[:, 0]) + )[:, np.newaxis] - def test_basis_fpca_transform_result(self): + np.testing.assert_allclose( + fpca.components_.coefficients, + results, + atol=1e-7, + ) + def test_basis_fpca_transform_result(self) -> None: + """Compare the scores in basis against the fda package.""" n_basis = 9 n_components = 3 fd_data = fetch_weather()['data'].coordinates[0] - fd_data = FDataGrid(np.squeeze(fd_data.data_matrix), - np.arange(0.5, 365, 1)) - # initialize basis data + # Initialize basis data basis = Fourier(n_basis=n_basis, domain_range=(0, 365)) fd_basis = fd_data.to_basis(basis) - fpca = FPCA(n_components=n_components, - regularization=TikhonovRegularization( - LinearDifferentialOperator(2), - regularization_parameter=1e5)) + fpca = FPCA( + n_components=n_components, + regularization=TikhonovRegularization( + LinearDifferentialOperator(2), + regularization_parameter=1e5, + ), + ) fpca.fit(fd_basis) scores = fpca.transform(fd_basis) - # results obtained using Ramsay's R package - results = [[-7.68307641e+01, 5.69034443e+01, -1.22440149e+01], - [-9.02873996e+01, 1.46262257e+01, -1.78574536e+01], - [-8.21155683e+01, 3.19159491e+01, -2.56212328e+01], - [-1.14163637e+02, 3.66425562e+01, -1.00810836e+01], - [-6.97263223e+01, 1.22817168e+01, -2.39417618e+01], - [-6.41886364e+01, -1.07261045e+01, -1.10587407e+01], - [1.35824412e+02, 2.03484658e+01, -9.04815324e+00], - [-1.46816399e+01, -2.66867491e+01, -1.20233465e+01], - [1.02507511e+00, -2.29840736e+01, -9.06081296e+00], - [-3.62936903e+01, -2.09520442e+01, -1.14799951e+01], - [-4.20649313e+01, -1.13618094e+01, -6.24909009e+00], - [-7.38115985e+01, -3.18423866e+01, -1.50298626e+01], - [-6.69822456e+01, -3.35518632e+01, -1.25167352e+01], - [-1.03534763e+02, -1.29513941e+01, -1.49103879e+01], - [-1.04542036e+02, -1.36794907e+01, -1.41555965e+01], - [-7.35863347e+00, -1.41171956e+01, -2.97562788e+00], - [7.28804530e+00, -5.34421830e+01, -3.39823418e+00], - [5.59974094e+01, -4.02154080e+01, 3.78800103e-01], - [1.80778702e+02, 1.87798201e+01, -1.99043247e+01], - [-3.69700617e+00, -4.19441020e+01, 6.45820740e+00], - [3.76527216e+01, -4.23056953e+01, 1.04221757e+01], - [1.23850646e+02, -4.24648130e+01, -2.22336786e-01], - [-7.23588457e+00, -1.20579536e+01, 2.07502089e+01], - [-4.96871011e+01, 8.88483448e+00, 2.02882768e+01], - [-1.36726355e+02, -1.86472599e+01, 1.89076217e+01], - [-1.83878661e+02, 4.12118550e+01, 1.78960356e+01], - [-1.81568820e+02, 5.20817910e+01, 2.01078870e+01], - [-5.08775852e+01, 1.34600555e+01, 3.18602712e+01], - [-1.37633866e+02, 7.50809631e+01, 2.42320782e+01], - [4.98276375e+01, 1.33401270e+00, 3.50611066e+01], - [1.51149934e+02, -5.47417776e+01, 3.97592325e+01], - [1.58366096e+02, -3.80762686e+01, -5.62415023e+00], - [2.17139548e+02, 6.34055987e+01, -1.98853635e+01], - [2.33615480e+02, -7.90787574e-02, 2.69069525e+00], - [3.45371437e+02, 9.58703622e+01, 8.47570770e+00]] - results = np.array(results) - - # compare results + # Results obtained using Ramsay's R package + results = np.array([ + [-7.68307641e1, 5.69034443e1, -1.22440149e1], + [-9.02873996e1, 1.46262257e1, -1.78574536e1], + [-8.21155683e1, 3.19159491e1, -2.56212328e1], + [-1.14163637e2, 3.66425562e1, -1.00810836e1], + [-6.97263223e1, 1.22817168e1, -2.39417618e1], + [-6.41886364e1, -1.07261045e1, -1.10587407e1], + [1.35824412e2, 2.03484658e1, -9.04815324e0], + [-1.46816399e1, -2.66867491e1, -1.20233465e1], + [1.02507511e0, -2.29840736e1, -9.06081296e0], + [-3.62936903e1, -2.09520442e1, -1.14799951e1], + [-4.20649313e1, -1.13618094e1, -6.24909009e0], + [-7.38115985e1, -3.18423866e1, -1.50298626e1], + [-6.69822456e1, -3.35518632e1, -1.25167352e1], + [-1.03534763e2, -1.29513941e1, -1.49103879e1], + [-1.04542036e2, -1.36794907e1, -1.41555965e1], + [-7.35863347e0, -1.41171956e1, -2.97562788e0], + [7.28804530e0, -5.34421830e1, -3.39823418e0], + [5.59974094e1, -4.02154080e1, 3.78800103e-1], + [1.80778702e2, 1.87798201e1, -1.99043247e1], + [-3.69700617e0, -4.19441020e1, 6.45820740e0], + [3.76527216e1, -4.23056953e1, 1.04221757e1], + [1.23850646e2, -4.24648130e1, -2.22336786e-1], + [-7.23588457e0, -1.20579536e1, 2.07502089e1], + [-4.96871011e1, 8.88483448e0, 2.02882768e1], + [-1.36726355e2, -1.86472599e1, 1.89076217e1], + [-1.83878661e2, 4.12118550e1, 1.78960356e1], + [-1.81568820e2, 5.20817910e1, 2.01078870e1], + [-5.08775852e1, 1.34600555e1, 3.18602712e1], + [-1.37633866e2, 7.50809631e1, 2.42320782e1], + [4.98276375e1, 1.33401270e0, 3.50611066e1], + [1.51149934e2, -5.47417776e1, 3.97592325e1], + [1.58366096e2, -3.80762686e1, -5.62415023e0], + [2.17139548e2, 6.34055987e1, -1.98853635e1], + [2.33615480e2, -7.90787574e-2, 2.69069525e0], + [3.45371437e2, 9.58703622e1, 8.47570770e0], + ]) + + # Compare results np.testing.assert_allclose(scores, results, atol=1e-7) - def test_basis_fpca_regularization_fit_result(self): - + def test_basis_fpca_noregularization_fit_result(self) -> None: + """Compare the components in basis against the fda package.""" n_basis = 9 n_components = 3 fd_data = fetch_weather()['data'].coordinates[0] - fd_data = FDataGrid(np.squeeze(fd_data.data_matrix), - np.arange(0.5, 365, 1)) - # initialize basis data + # Initialize basis data basis = Fourier(n_basis=n_basis, domain_range=(0, 365)) fd_basis = fd_data.to_basis(basis) fpca = FPCA(n_components=n_components) fpca.fit(fd_basis) - # results obtained using Ramsay's R package - results = [[0.9231551, 0.1364966, 0.3569451, 0.0092012, -0.0244525, - -0.02923873, -0.003566887, -0.009654571, -0.0100063], - [-0.3315211, -0.0508643, 0.89218521, 0.1669182, 0.2453900, - 0.03548997, 0.037938051, -0.025777507, 0.008416904], - [-0.1379108, 0.9125089, 0.00142045, 0.2657423, -0.2146497, - 0.16833314, 0.031509179, -0.006768189, 0.047306718]] - results = np.array(results) - - # compare results obtained using this library. There are slight + # Results obtained using Ramsay's R package + results = np.array([ + [ # noqa: WPS317 + 0.9231551, 0.1364966, 0.3569451, 0.0092012, -0.0244525, + -0.02923873, -0.003566887, -0.009654571, -0.0100063, + ], + [ # noqa: WPS317 + -0.3315211, -0.0508643, 0.89218521, 0.1669182, 0.24539, + 0.03548997, 0.037938051, -0.025777507, 0.008416904, + ], + [ # noqa: WPS317 + -0.1379108, 0.9125089, 0.00142045, 0.2657423, -0.2146497, + 0.16833314, 0.031509179, -0.006768189, 0.047306718, + ], + ]) + + # Compare results obtained using this library. There are slight # variations due to the fact that we are in two different packages - for i in range(n_components): - if np.sign(fpca.components_.coefficients[i][0]) != np.sign( - results[i][0]): - results[i, :] *= -1 - np.testing.assert_allclose(fpca.components_.coefficients, results, - atol=1e-7) + # If the sign of the components is not the same the component is + # reflected. + results *= ( + np.sign(fpca.components_.coefficients[:, 0]) + * np.sign(results[:, 0]) + )[:, np.newaxis] - def test_grid_fpca_fit_result(self): + np.testing.assert_allclose( + fpca.components_.coefficients, + results, + atol=1e-7, + ) + def test_grid_fpca_fit_result(self) -> None: + """Compare the components in grid against the fda.usc package.""" n_components = 1 fd_data = fetch_weather()['data'].coordinates[0] @@ -190,98 +222,100 @@ def test_grid_fpca_fit_result(self): fpca = FPCA(n_components=n_components, weights=[1] * 365) fpca.fit(fd_data) - # results obtained using fda.usc for the first component - results = [ - [-0.06958281, -0.07015412, -0.07095115, -0.07185632, -0.07128256, - -0.07124209, -0.07364828, -0.07297663, -0.07235438, -0.07307498, - -0.07293423, -0.07449293, -0.07647909, -0.07796823, -0.07582476, - -0.07263243, -0.07241871, -0.0718136, -0.07015477, -0.07132331, - -0.0711527, -0.07435933, -0.07602666, -0.0769783, -0.07707199, - -0.07503802, -0.0770302, -0.07705581, -0.07633515, -0.07624817, - -0.07631568, -0.07619913, -0.07568, -0.07595155, -0.07506939, - -0.07181941, -0.06907624, -0.06735476, -0.06853985, -0.06902363, - -0.07098882, -0.07479412, -0.07425241, -0.07555835, -0.0765903, - -0.07651853, -0.07682536, -0.07458996, -0.07631711, -0.07726509, - -0.07641246, -0.0744066, -0.07501397, -0.07302722, -0.07045571, - -0.06912529, -0.06792186, -0.06830739, -0.06898433, -0.07000192, - -0.07014513, -0.06994886, -0.07115909, -0.073999, -0.07292669, - -0.07139879, -0.07226865, -0.07187915, -0.07122995, -0.06975022, - -0.06800613, -0.06900793, -0.07186378, -0.07114479, -0.07015252, - -0.06944782, -0.068291, -0.06905348, -0.06925773, -0.06834624, - -0.06837319, -0.06824067, -0.06644614, -0.06637313, -0.06626312, - -0.06470209, -0.0645058, -0.06477729, -0.06411049, -0.06158499, - -0.06305197, -0.06398006, -0.06277579, -0.06282124, -0.06317684, - -0.0614125, -0.05961922, -0.05875443, -0.05845781, -0.05828608, - -0.05666474, -0.05495706, -0.05446301, -0.05468254, -0.05478609, - -0.05440798, -0.05312339, -0.05102368, -0.05160285, -0.05077954, - -0.04979648, -0.04890853, -0.04745462, -0.04496763, -0.0448713, - -0.04599596, -0.04688998, -0.04488872, -0.04404507, -0.04420729, - -0.04368153, -0.04254381, -0.0411764, -0.04022811, -0.03999746, - -0.03963634, -0.03832502, -0.0383956, -0.04015374, -0.0387544, - -0.03777315, -0.03830728, -0.03768616, -0.03714081, -0.03781918, - -0.03739374, -0.03659894, -0.03563342, -0.03658407, -0.03686991, - -0.03543746, -0.03518799, -0.03361226, -0.0321534, -0.03050438, - -0.02958411, -0.02855023, -0.02913402, -0.02992464, -0.02899548, - -0.02891629, -0.02809554, -0.02702642, -0.02672194, -0.02678648, - -0.02698471, -0.02628085, -0.02674285, -0.02658515, -0.02604447, - -0.0245711, -0.02413174, -0.02342496, -0.022898, -0.02216152, - -0.02272283, -0.02199741, -0.02305362, -0.02371371, -0.02320865, - -0.02234777, -0.0225018, -0.02104359, -0.02203346, -0.02052545, - -0.01987457, -0.01947911, -0.01986949, -0.02012196, -0.01958515, - -0.01906753, -0.01857869, -0.01874101, -0.01827973, -0.017752, - -0.01702056, -0.01759611, -0.01888485, -0.01988159, -0.01951675, - -0.01872967, -0.01866667, -0.0183576, -0.01909758, -0.018599, - -0.01910036, -0.01930315, -0.01958856, -0.02129936, -0.0216614, - -0.0204397, -0.02002368, -0.02058828, -0.02149915, -0.02167326, - -0.02238569, -0.02211907, -0.02168336, -0.02124387, -0.02131655, - -0.02130508, -0.02181227, -0.02230632, -0.02223732, -0.0228216, - -0.02355137, -0.02275145, -0.02286893, -0.02437776, -0.02523897, - -0.0248354, -0.02319174, -0.02335831, -0.02405789, -0.02483273, - -0.02428119, -0.02395295, -0.02437185, -0.02476434, -0.02347973, - -0.02385957, -0.02451257, -0.02414586, -0.02439035, -0.02357782, - -0.02417295, -0.02504764, -0.02682569, -0.02807111, -0.02886335, - -0.02943406, -0.02956806, -0.02893096, -0.02903812, -0.02999862, - -0.029421, -0.03016203, -0.03118823, -0.03076205, -0.03005985, - -0.03079187, -0.03215188, -0.03271075, -0.03146124, -0.03040965, - -0.03008436, -0.03085897, -0.03015341, -0.03014661, -0.03110255, - -0.03271278, -0.03217399, -0.0331721, -0.03459221, -0.03572073, - -0.03560707, -0.03531492, -0.03687657, -0.03800143, -0.0373808, - -0.03729927, -0.03748666, -0.03754171, -0.03790408, -0.03963726, - -0.03992153, -0.03812243, -0.0373844, -0.0385394, -0.03849716, - -0.03826345, -0.03743958, -0.0380861, -0.03857622, -0.04099357, - -0.04102509, -0.04170207, -0.04283573, -0.04320618, -0.04269438, - -0.04467527, -0.04470603, -0.04496092, -0.04796417, -0.04796633, - -0.047863, -0.04883668, -0.0505939, -0.05112441, -0.04960962, - -0.05000041, -0.04962112, -0.05087008, -0.0521671, -0.05369792, - -0.05478139, -0.05559221, -0.05669698, -0.05654505, -0.05731113, - -0.05783543, -0.05766056, -0.05754354, -0.05724272, -0.05831026, - -0.05847512, -0.05804533, -0.05875046, -0.06021703, -0.06147975, - -0.06213918, -0.0645805, -0.06500849, -0.06361716, -0.06315227, - -0.06306436, -0.06425743, -0.06626847, -0.06615213, -0.06881004, - -0.06942296, -0.06889225, -0.06868663, -0.0678667, -0.06720133, - -0.06771172, -0.06885042, -0.06896979, -0.06961627, -0.07211988, - -0.07252956, -0.07265559, -0.07264195, -0.07306334, -0.07282035, - -0.07196505, -0.07210595, -0.07203942, -0.07105821, -0.06920599, - -0.06892264, -0.06699939, -0.06537829, -0.06543323, -0.06913186, - -0.07210039, -0.07219987, -0.07124228, -0.07065497, -0.06996833, - -0.0674457, -0.06800847, -0.06784175, -0.06592871, -0.06723401]] - - results = np.array(results) - - # compare results obtained using this library. There are slight + # Results obtained using fda.usc for the first component + results = np.array([ # noqa: WPS317 + -0.06958281, -0.07015412, -0.07095115, -0.07185632, -0.07128256, + -0.07124209, -0.07364828, -0.07297663, -0.07235438, -0.07307498, + -0.07293423, -0.07449293, -0.07647909, -0.07796823, -0.07582476, + -0.07263243, -0.07241871, -0.0718136, -0.07015477, -0.07132331, + -0.0711527, -0.07435933, -0.07602666, -0.0769783, -0.07707199, + -0.07503802, -0.0770302, -0.07705581, -0.07633515, -0.07624817, + -0.07631568, -0.07619913, -0.07568, -0.07595155, -0.07506939, + -0.07181941, -0.06907624, -0.06735476, -0.06853985, -0.06902363, + -0.07098882, -0.07479412, -0.07425241, -0.07555835, -0.0765903, + -0.07651853, -0.07682536, -0.07458996, -0.07631711, -0.07726509, + -0.07641246, -0.0744066, -0.07501397, -0.07302722, -0.07045571, + -0.06912529, -0.06792186, -0.06830739, -0.06898433, -0.07000192, + -0.07014513, -0.06994886, -0.07115909, -0.073999, -0.07292669, + -0.07139879, -0.07226865, -0.07187915, -0.07122995, -0.06975022, + -0.06800613, -0.06900793, -0.07186378, -0.07114479, -0.07015252, + -0.06944782, -0.068291, -0.06905348, -0.06925773, -0.06834624, + -0.06837319, -0.06824067, -0.06644614, -0.06637313, -0.06626312, + -0.06470209, -0.0645058, -0.06477729, -0.06411049, -0.06158499, + -0.06305197, -0.06398006, -0.06277579, -0.06282124, -0.06317684, + -0.0614125, -0.05961922, -0.05875443, -0.05845781, -0.05828608, + -0.05666474, -0.05495706, -0.05446301, -0.05468254, -0.05478609, + -0.05440798, -0.05312339, -0.05102368, -0.05160285, -0.05077954, + -0.04979648, -0.04890853, -0.04745462, -0.04496763, -0.0448713, + -0.04599596, -0.04688998, -0.04488872, -0.04404507, -0.04420729, + -0.04368153, -0.04254381, -0.0411764, -0.04022811, -0.03999746, + -0.03963634, -0.03832502, -0.0383956, -0.04015374, -0.0387544, + -0.03777315, -0.03830728, -0.03768616, -0.03714081, -0.03781918, + -0.03739374, -0.03659894, -0.03563342, -0.03658407, -0.03686991, + -0.03543746, -0.03518799, -0.03361226, -0.0321534, -0.03050438, + -0.02958411, -0.02855023, -0.02913402, -0.02992464, -0.02899548, + -0.02891629, -0.02809554, -0.02702642, -0.02672194, -0.02678648, + -0.02698471, -0.02628085, -0.02674285, -0.02658515, -0.02604447, + -0.0245711, -0.02413174, -0.02342496, -0.022898, -0.02216152, + -0.02272283, -0.02199741, -0.02305362, -0.02371371, -0.02320865, + -0.02234777, -0.0225018, -0.02104359, -0.02203346, -0.02052545, + -0.01987457, -0.01947911, -0.01986949, -0.02012196, -0.01958515, + -0.01906753, -0.01857869, -0.01874101, -0.01827973, -0.017752, + -0.01702056, -0.01759611, -0.01888485, -0.01988159, -0.01951675, + -0.01872967, -0.01866667, -0.0183576, -0.01909758, -0.018599, + -0.01910036, -0.01930315, -0.01958856, -0.02129936, -0.0216614, + -0.0204397, -0.02002368, -0.02058828, -0.02149915, -0.02167326, + -0.02238569, -0.02211907, -0.02168336, -0.02124387, -0.02131655, + -0.02130508, -0.02181227, -0.02230632, -0.02223732, -0.0228216, + -0.02355137, -0.02275145, -0.02286893, -0.02437776, -0.02523897, + -0.0248354, -0.02319174, -0.02335831, -0.02405789, -0.02483273, + -0.02428119, -0.02395295, -0.02437185, -0.02476434, -0.02347973, + -0.02385957, -0.02451257, -0.02414586, -0.02439035, -0.02357782, + -0.02417295, -0.02504764, -0.02682569, -0.02807111, -0.02886335, + -0.02943406, -0.02956806, -0.02893096, -0.02903812, -0.02999862, + -0.029421, -0.03016203, -0.03118823, -0.03076205, -0.03005985, + -0.03079187, -0.03215188, -0.03271075, -0.03146124, -0.03040965, + -0.03008436, -0.03085897, -0.03015341, -0.03014661, -0.03110255, + -0.03271278, -0.03217399, -0.0331721, -0.03459221, -0.03572073, + -0.03560707, -0.03531492, -0.03687657, -0.03800143, -0.0373808, + -0.03729927, -0.03748666, -0.03754171, -0.03790408, -0.03963726, + -0.03992153, -0.03812243, -0.0373844, -0.0385394, -0.03849716, + -0.03826345, -0.03743958, -0.0380861, -0.03857622, -0.04099357, + -0.04102509, -0.04170207, -0.04283573, -0.04320618, -0.04269438, + -0.04467527, -0.04470603, -0.04496092, -0.04796417, -0.04796633, + -0.047863, -0.04883668, -0.0505939, -0.05112441, -0.04960962, + -0.05000041, -0.04962112, -0.05087008, -0.0521671, -0.05369792, + -0.05478139, -0.05559221, -0.05669698, -0.05654505, -0.05731113, + -0.05783543, -0.05766056, -0.05754354, -0.05724272, -0.05831026, + -0.05847512, -0.05804533, -0.05875046, -0.06021703, -0.06147975, + -0.06213918, -0.0645805, -0.06500849, -0.06361716, -0.06315227, + -0.06306436, -0.06425743, -0.06626847, -0.06615213, -0.06881004, + -0.06942296, -0.06889225, -0.06868663, -0.0678667, -0.06720133, + -0.06771172, -0.06885042, -0.06896979, -0.06961627, -0.07211988, + -0.07252956, -0.07265559, -0.07264195, -0.07306334, -0.07282035, + -0.07196505, -0.07210595, -0.07203942, -0.07105821, -0.06920599, + -0.06892264, -0.06699939, -0.06537829, -0.06543323, -0.06913186, + -0.07210039, -0.07219987, -0.07124228, -0.07065497, -0.06996833, + -0.0674457, -0.06800847, -0.06784175, -0.06592871, -0.06723401, + ]) + + # Compare results obtained using this library. There are slight # variations due to the fact that we are in two different packages - for i in range(n_components): - if np.sign(fpca.components_.data_matrix[i][0]) != np.sign( - results[i][0]): - results[i, :] *= -1 + # If the sign of the components is not the same the component is + # reflected. + results *= ( + np.sign(fpca.components_.data_matrix.ravel()[0]) + * np.sign(results[0]) + ) + np.testing.assert_allclose( - fpca.components_.data_matrix.reshape( - fpca.components_.data_matrix.shape[:-1]), + fpca.components_.data_matrix.ravel(), results, - rtol=1e-6) - - def test_grid_fpca_transform_result(self): + rtol=1e-6, + ) + def test_grid_fpca_transform_result(self) -> None: + """Compare the scores in grid against the fda.usc package.""" n_components = 1 fd_data = fetch_weather()['data'].coordinates[0] @@ -291,126 +325,129 @@ def test_grid_fpca_transform_result(self): scores = fpca.transform(fd_data) # results obtained - results = [[-77.05020176], [-90.56072204], [-82.39565947], - [-114.45375934], [-69.99735931], [-64.44894047], - [135.58336775], [-14.93460852], [0.75024737], - [-36.4781038], [-42.35637749], [-73.98910492], - [-67.11253749], [-103.68269798], [-104.65948079], - [-7.42817782], [7.48125036], [56.29792942], - [181.00258791], [-3.53294736], [37.94673912], - [124.43819913], [-7.04274676], [-49.61134859], - [-136.86256785], [-184.03502398], [-181.72835749], - [-51.06323208], [-137.85606731], [50.10941466], - [151.68118097], [159.01360046], [217.17981302], - [234.40195237], [345.39374006]] - results = np.array(results) + results = np.array([ # noqa: WPS317 + [-77.05020176], [-90.56072204], [-82.39565947], + [-114.45375934], [-69.99735931], [-64.44894047], + [135.58336775], [-14.93460852], [0.75024737], + [-36.4781038], [-42.35637749], [-73.98910492], + [-67.11253749], [-103.68269798], [-104.65948079], + [-7.42817782], [7.48125036], [56.29792942], + [181.00258791], [-3.53294736], [37.94673912], + [124.43819913], [-7.04274676], [-49.61134859], + [-136.86256785], [-184.03502398], [-181.72835749], + [-51.06323208], [-137.85606731], [50.10941466], + [151.68118097], [159.01360046], [217.17981302], + [234.40195237], [345.39374006], + ]) np.testing.assert_allclose(scores, results, rtol=1e-6) - def test_grid_fpca_regularization_fit_result(self): - + def test_grid_fpca_regularization_fit_result(self) -> None: + """Compare the components in grid against the fda.usc package.""" n_components = 1 fd_data = fetch_weather()['data'].coordinates[0] - fd_data = FDataGrid(np.squeeze(fd_data.data_matrix), - np.arange(0.5, 365, 1)) - fpca = FPCA( - n_components=n_components, weights=[1] * 365, + n_components=n_components, + weights=[1] * 365, regularization=TikhonovRegularization( - LinearDifferentialOperator(2))) + LinearDifferentialOperator(2), + ), + ) fpca.fit(fd_data) - # results obtained using fda.usc for the first component - results = [ - [-0.06961236, -0.07027042, -0.07090496, -0.07138247, -0.07162215, - -0.07202264, -0.07264893, -0.07279174, -0.07274672, -0.07300075, - -0.07365471, -0.07489002, -0.07617455, -0.07658708, -0.07551923, - -0.07375128, -0.0723776, -0.07138373, -0.07080555, -0.07111745, - -0.0721514, -0.07395427, -0.07558341, -0.07650959, -0.0766541, - -0.07641352, -0.07660864, -0.07669081, -0.0765396, -0.07640671, - -0.07634668, -0.07626304, -0.07603638, -0.07549114, -0.07410347, - -0.07181791, -0.06955356, -0.06824034, -0.06834077, -0.06944125, - -0.07133598, -0.07341109, -0.07471501, -0.07568844, -0.07631904, - -0.07647264, -0.07629453, -0.07598431, -0.07628157, -0.07654062, - -0.07616026, -0.07527189, -0.07426683, -0.07267961, -0.07079998, - -0.06927394, -0.068412, -0.06838534, -0.06888439, -0.0695309, - -0.07005508, -0.07066637, -0.07167196, -0.07266978, -0.07275299, - -0.07235183, -0.07207819, -0.07159814, -0.07077697, -0.06977026, - -0.0691952, -0.06965756, -0.07058327, -0.07075751, -0.07025415, - -0.06954233, -0.06899785, -0.06891026, -0.06887079, -0.06862183, - -0.06830082, -0.06777765, -0.06700202, -0.06639394, -0.06582435, - -0.06514987, -0.06467236, -0.06425272, -0.06359187, -0.062922, - -0.06300068, -0.06325494, -0.06316979, -0.06296254, -0.06246343, - -0.06136836, -0.0600936, -0.05910688, -0.05840872, -0.0576547, - -0.05655684, -0.05546518, -0.05484433, -0.05465746, -0.05449286, - -0.05397004, -0.05300742, -0.05196686, -0.05133129, -0.05064617, - -0.04973418, -0.04855687, -0.04714356, -0.04588103, -0.04547284, - -0.04571493, -0.04580704, -0.04523509, -0.04457293, -0.04405309, - -0.04338468, -0.04243512, -0.04137278, -0.04047946, -0.03984531, - -0.03931376, -0.0388847, -0.03888507, -0.03908662, -0.03877577, - -0.03830952, -0.03802713, -0.03773521, -0.03752388, -0.03743759, - -0.03714113, -0.03668387, -0.0363703, -0.03642288, -0.03633051, - -0.03574618, -0.03486536, -0.03357797, -0.03209969, -0.0306837, - -0.02963987, -0.029102, -0.0291513, -0.02932013, -0.02912619, - -0.02869407, -0.02801974, -0.02732363, -0.02690451, -0.02676622, - -0.0267323, -0.02664896, -0.02661708, -0.02637166, -0.02577496, - -0.02490428, -0.02410813, -0.02340367, -0.02283356, -0.02246305, - -0.0224229, -0.0225435, -0.02295603, -0.02324663, -0.02310005, - -0.02266893, -0.02221522, -0.02168056, -0.02129419, -0.02064909, - -0.02007801, -0.01979083, -0.01979541, -0.01978879, -0.01954269, - -0.0191623, -0.01879572, -0.01849678, -0.01810297, -0.01769666, - -0.01753802, -0.01794351, -0.01871307, -0.01930005, -0.01933, - -0.01901017, -0.01873486, -0.01861838, -0.01870777, -0.01879, - -0.01904219, -0.01945078, -0.0200607, -0.02076936, -0.02100213, - -0.02071439, -0.02052113, -0.02076313, -0.02128468, -0.02175631, - -0.02206387, -0.02201054, -0.02172142, -0.02143092, -0.02133647, - -0.02144956, -0.02176286, -0.02212579, -0.02243861, -0.02278316, - -0.02304113, -0.02313356, -0.02349275, -0.02417028, -0.0245954, - -0.0244062, -0.02388557, -0.02374682, -0.02401071, -0.02431126, - -0.02433125, -0.02427656, -0.02430442, -0.02424977, -0.02401619, - -0.02402294, -0.02415424, -0.02413262, -0.02404076, -0.02397651, - -0.0243893, -0.0253322, -0.02664395, -0.0278802, -0.02877936, - -0.02927182, -0.02937318, -0.02926277, -0.02931632, -0.02957945, - -0.02982133, -0.03023224, -0.03060406, -0.03066011, -0.03070932, - -0.03116429, -0.03179009, -0.03198094, -0.03149462, -0.03082037, - -0.03041594, -0.0303307, -0.03028465, -0.03052841, -0.0311837, - -0.03199307, -0.03262025, -0.03345083, -0.03442665, -0.03521313, - -0.0356433, -0.03606037, -0.03677406, -0.03735165, -0.03746578, - -0.03744154, -0.03752143, -0.03780898, -0.03837639, -0.03903232, - -0.03911629, -0.03857567, -0.03816592, -0.03819285, -0.03818405, - -0.03801684, -0.03788493, -0.03823232, -0.03906142, -0.04023251, - -0.04112434, -0.04188011, -0.04254759, -0.043, -0.04340181, - -0.04412687, -0.04484482, -0.04577669, -0.04700832, -0.04781373, - -0.04842662, -0.04923723, -0.05007637, -0.05037817, -0.05009794, - -0.04994083, -0.05012712, -0.05094001, -0.05216065, -0.05350458, - -0.05469781, -0.05566309, -0.05641011, -0.05688106, -0.05730818, - -0.05759156, -0.05763771, -0.05760073, -0.05766117, -0.05794587, - -0.05816696, -0.0584046, -0.05905105, -0.06014331, -0.06142231, - -0.06270788, -0.06388225, -0.06426245, -0.06386721, -0.0634656, - -0.06358049, -0.06442514, -0.06570047, -0.06694328, -0.0682621, - -0.06897846, -0.06896583, -0.06854621, -0.06797142, -0.06763755, - -0.06784024, -0.06844314, -0.06918567, -0.07021928, -0.07148473, - -0.07232504, -0.07272276, -0.07287021, -0.07289836, -0.07271531, - -0.07239956, -0.07214086, -0.07170078, -0.07081195, -0.06955202, - -0.06825156, -0.06690167, -0.06617102, -0.06683291, -0.06887539, - -0.07089424, -0.07174837, -0.07150888, -0.07070378, -0.06960066, - -0.06842496, -0.06777666, -0.06728403, -0.06681262, -0.06679066]] - - results = np.array(results) - - # compare results obtained using this library. There are slight + # Results obtained using fda.usc for the first component + results = np.array([ # noqa: WPS317 + -0.06961236, -0.07027042, -0.07090496, -0.07138247, -0.07162215, + -0.07202264, -0.07264893, -0.07279174, -0.07274672, -0.07300075, + -0.07365471, -0.07489002, -0.07617455, -0.07658708, -0.07551923, + -0.07375128, -0.0723776, -0.07138373, -0.07080555, -0.07111745, + -0.0721514, -0.07395427, -0.07558341, -0.07650959, -0.0766541, + -0.07641352, -0.07660864, -0.07669081, -0.0765396, -0.07640671, + -0.07634668, -0.07626304, -0.07603638, -0.07549114, -0.07410347, + -0.07181791, -0.06955356, -0.06824034, -0.06834077, -0.06944125, + -0.07133598, -0.07341109, -0.07471501, -0.07568844, -0.07631904, + -0.07647264, -0.07629453, -0.07598431, -0.07628157, -0.07654062, + -0.07616026, -0.07527189, -0.07426683, -0.07267961, -0.07079998, + -0.06927394, -0.068412, -0.06838534, -0.06888439, -0.0695309, + -0.07005508, -0.07066637, -0.07167196, -0.07266978, -0.07275299, + -0.07235183, -0.07207819, -0.07159814, -0.07077697, -0.06977026, + -0.0691952, -0.06965756, -0.07058327, -0.07075751, -0.07025415, + -0.06954233, -0.06899785, -0.06891026, -0.06887079, -0.06862183, + -0.06830082, -0.06777765, -0.06700202, -0.06639394, -0.06582435, + -0.06514987, -0.06467236, -0.06425272, -0.06359187, -0.062922, + -0.06300068, -0.06325494, -0.06316979, -0.06296254, -0.06246343, + -0.06136836, -0.0600936, -0.05910688, -0.05840872, -0.0576547, + -0.05655684, -0.05546518, -0.05484433, -0.05465746, -0.05449286, + -0.05397004, -0.05300742, -0.05196686, -0.05133129, -0.05064617, + -0.04973418, -0.04855687, -0.04714356, -0.04588103, -0.04547284, + -0.04571493, -0.04580704, -0.04523509, -0.04457293, -0.04405309, + -0.04338468, -0.04243512, -0.04137278, -0.04047946, -0.03984531, + -0.03931376, -0.0388847, -0.03888507, -0.03908662, -0.03877577, + -0.03830952, -0.03802713, -0.03773521, -0.03752388, -0.03743759, + -0.03714113, -0.03668387, -0.0363703, -0.03642288, -0.03633051, + -0.03574618, -0.03486536, -0.03357797, -0.03209969, -0.0306837, + -0.02963987, -0.029102, -0.0291513, -0.02932013, -0.02912619, + -0.02869407, -0.02801974, -0.02732363, -0.02690451, -0.02676622, + -0.0267323, -0.02664896, -0.02661708, -0.02637166, -0.02577496, + -0.02490428, -0.02410813, -0.02340367, -0.02283356, -0.02246305, + -0.0224229, -0.0225435, -0.02295603, -0.02324663, -0.02310005, + -0.02266893, -0.02221522, -0.02168056, -0.02129419, -0.02064909, + -0.02007801, -0.01979083, -0.01979541, -0.01978879, -0.01954269, + -0.0191623, -0.01879572, -0.01849678, -0.01810297, -0.01769666, + -0.01753802, -0.01794351, -0.01871307, -0.01930005, -0.01933, + -0.01901017, -0.01873486, -0.01861838, -0.01870777, -0.01879, + -0.01904219, -0.01945078, -0.0200607, -0.02076936, -0.02100213, + -0.02071439, -0.02052113, -0.02076313, -0.02128468, -0.02175631, + -0.02206387, -0.02201054, -0.02172142, -0.02143092, -0.02133647, + -0.02144956, -0.02176286, -0.02212579, -0.02243861, -0.02278316, + -0.02304113, -0.02313356, -0.02349275, -0.02417028, -0.0245954, + -0.0244062, -0.02388557, -0.02374682, -0.02401071, -0.02431126, + -0.02433125, -0.02427656, -0.02430442, -0.02424977, -0.02401619, + -0.02402294, -0.02415424, -0.02413262, -0.02404076, -0.02397651, + -0.0243893, -0.0253322, -0.02664395, -0.0278802, -0.02877936, + -0.02927182, -0.02937318, -0.02926277, -0.02931632, -0.02957945, + -0.02982133, -0.03023224, -0.03060406, -0.03066011, -0.03070932, + -0.03116429, -0.03179009, -0.03198094, -0.03149462, -0.03082037, + -0.03041594, -0.0303307, -0.03028465, -0.03052841, -0.0311837, + -0.03199307, -0.03262025, -0.03345083, -0.03442665, -0.03521313, + -0.0356433, -0.03606037, -0.03677406, -0.03735165, -0.03746578, + -0.03744154, -0.03752143, -0.03780898, -0.03837639, -0.03903232, + -0.03911629, -0.03857567, -0.03816592, -0.03819285, -0.03818405, + -0.03801684, -0.03788493, -0.03823232, -0.03906142, -0.04023251, + -0.04112434, -0.04188011, -0.04254759, -0.043, -0.04340181, + -0.04412687, -0.04484482, -0.04577669, -0.04700832, -0.04781373, + -0.04842662, -0.04923723, -0.05007637, -0.05037817, -0.05009794, + -0.04994083, -0.05012712, -0.05094001, -0.05216065, -0.05350458, + -0.05469781, -0.05566309, -0.05641011, -0.05688106, -0.05730818, + -0.05759156, -0.05763771, -0.05760073, -0.05766117, -0.05794587, + -0.05816696, -0.0584046, -0.05905105, -0.06014331, -0.06142231, + -0.06270788, -0.06388225, -0.06426245, -0.06386721, -0.0634656, + -0.06358049, -0.06442514, -0.06570047, -0.06694328, -0.0682621, + -0.06897846, -0.06896583, -0.06854621, -0.06797142, -0.06763755, + -0.06784024, -0.06844314, -0.06918567, -0.07021928, -0.07148473, + -0.07232504, -0.07272276, -0.07287021, -0.07289836, -0.07271531, + -0.07239956, -0.07214086, -0.07170078, -0.07081195, -0.06955202, + -0.06825156, -0.06690167, -0.06617102, -0.06683291, -0.06887539, + -0.07089424, -0.07174837, -0.07150888, -0.07070378, -0.06960066, + -0.06842496, -0.06777666, -0.06728403, -0.06681262, -0.06679066, + ]) + + # Compare results obtained using this library. There are slight # variations due to the fact that we are in two different packages - for i in range(n_components): - if np.sign(fpca.components_.data_matrix[i][0]) != np.sign( - results[i][0]): - results[i, :] *= -1 + # If the sign of the components is not the same the component is + # reflected. + results *= ( + np.sign(fpca.components_.data_matrix.ravel()[0]) + * np.sign(results[0]) + ) + np.testing.assert_allclose( - fpca.components_.data_matrix.reshape( - fpca.components_.data_matrix.shape[:-1]), + fpca.components_.data_matrix.ravel(), results, - rtol=1e-2) + rtol=1e-2, + ) if __name__ == '__main__': diff --git a/tests/test_linear_differential_operator.py b/tests/test_linear_differential_operator.py index d871f70bc..e676de8b7 100644 --- a/tests/test_linear_differential_operator.py +++ b/tests/test_linear_differential_operator.py @@ -1,14 +1,25 @@ +"""Tests of the LinearDifferentialOperator.""" + import unittest +from typing import Callable, Sequence, Union import numpy as np from skfda.misc.operators import LinearDifferentialOperator from skfda.representation.basis import Constant, FDataBasis, Monomial +WeightCallable = Callable[[np.ndarray], np.ndarray] -class TestLinearDifferentialOperator(unittest.TestCase): - def _assert_equal_weights(self, weights, weights2, msg): +class TestLinearDifferentialOperator(unittest.TestCase): + """Tests of the linear differential operator.""" + + def _assert_equal_weights( + self, + weights: Sequence[Union[float, WeightCallable]], + weights2: Sequence[Union[float, WeightCallable]], + msg: str, + ) -> None: self.assertEqual(len(weights), len(weights2), msg) for w, w2 in zip(weights, weights2): @@ -20,59 +31,57 @@ def _assert_equal_weights(self, weights, weights2, msg): else: self.assertTrue(eq(w2), msg) - def test_init_default(self): + def test_init_default(self) -> None: """Tests default initialization (do not penalize).""" lfd = LinearDifferentialOperator() - weightfd = [FDataBasis(Constant(domain_range=(0, 1)), 0)] + weights = [0] self._assert_equal_weights( - lfd.weights, weightfd, - "Wrong list of weight functions of the linear operator") + lfd.weights, + weights, + "Wrong list of weight functions of the linear operator", + ) - def test_init_integer(self): + def test_init_integer(self) -> None: """Tests initializations which only specify the order.""" - # Checks for a zero order Lfd object lfd_0 = LinearDifferentialOperator(order=0) - weightfd = [FDataBasis(Constant(domain_range=(0, 1)), 1)] + weights = [1] self._assert_equal_weights( - lfd_0.weights, weightfd, - "Wrong list of weight functions of the linear operator") + lfd_0.weights, + weights, + "Wrong list of weight functions of the linear operator", + ) # Checks for a non zero order Lfd object lfd_3 = LinearDifferentialOperator(3) - consfd = FDataBasis( - Constant(domain_range=(0, 1)), - [[0], [0], [0], [1]], - ) - bwtlist3 = list(consfd) + weights = [0, 0, 0, 1] self._assert_equal_weights( - lfd_3.weights, bwtlist3, - "Wrong list of weight functions of the linear operator") + lfd_3.weights, + weights, + "Wrong list of weight functions of the linear operator", + ) # Negative order must fail with np.testing.assert_raises(ValueError): LinearDifferentialOperator(-1) - def test_init_list_int(self): + def test_init_list_int(self) -> None: """Tests initializations with integer weights.""" + weights = [1, 3, 4, 5, 6, 7] - coefficients = [1, 3, 4, 5, 6, 7] - - constant = Constant((0, 1)) - fd = FDataBasis(constant, np.array(coefficients).reshape(-1, 1)) - - lfd = LinearDifferentialOperator(weights=coefficients) + lfd = LinearDifferentialOperator(weights=weights) self._assert_equal_weights( - lfd.weights, list(fd), - "Wrong list of weight functions of the linear operator") + lfd.weights, + weights, + "Wrong list of weight functions of the linear operator", + ) - def test_init_list_fdatabasis(self): + def test_init_list_fdatabasis(self) -> None: """Test initialization with functional weights.""" - n_basis = 4 n_weights = 6 @@ -86,8 +95,10 @@ def test_init_list_fdatabasis(self): lfd = LinearDifferentialOperator(weights=fdlist) self._assert_equal_weights( - lfd.weights, list(fd), - "Wrong list of weight functions of the linear operator") + lfd.weights, + list(fd), + "Wrong list of weight functions of the linear operator", + ) # Check failure if intervals do not match constant = Constant(domain_range=(0, 2)) @@ -95,8 +106,8 @@ def test_init_list_fdatabasis(self): with np.testing.assert_raises(ValueError): LinearDifferentialOperator(weights=fdlist) - def test_init_wrong_params(self): - + def test_init_wrong_params(self) -> None: + """Check invalid parameters.""" # Check specifying both arguments fail with np.testing.assert_raises(ValueError): LinearDifferentialOperator(1, weights=[1, 1]) @@ -106,17 +117,11 @@ def test_init_wrong_params(self): fdlist = [FDataBasis(monomial, [1, 2, 3])] with np.testing.assert_raises(ValueError): - LinearDifferentialOperator(weights=fdlist, - domain_range=(0, 2)) - - # Check wrong types fail - with np.testing.assert_raises(ValueError): - LinearDifferentialOperator(weights=['a']) - - with np.testing.assert_raises(ValueError): - LinearDifferentialOperator(weights='a') + LinearDifferentialOperator( + weights=fdlist, + domain_range=(0, 2), + ) if __name__ == '__main__': - print() unittest.main() diff --git a/tests/test_math.py b/tests/test_math.py index d86be7b40..dee31333f 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -1,10 +1,11 @@ -import skfda -from skfda._utils import _pairwise_commutative -from skfda.representation.basis import Monomial, Tensor, VectorValued import unittest import numpy as np +import skfda +from skfda._utils import _pairwise_symmetric +from skfda.representation.basis import Monomial, Tensor, VectorValued + def ndm(*args): return [x[(None,) * i + (slice(None),) + (None,) * (len(args) - i - 1)] @@ -18,7 +19,7 @@ def test_several_variables(self): def f(x, y, z): return x * y * z - t = np.linspace(0, 1, 100) + t = np.linspace(0, 1, 30) x2, y2, z2 = ndm(t, 2 * t, 3 * t) @@ -38,9 +39,9 @@ def f(x, y, z): res = 8 np.testing.assert_allclose( - skfda.misc.inner_product(fd, fd), res, rtol=1e-5) + skfda.misc.inner_product(fd, fd), res, rtol=1e-4) np.testing.assert_allclose( - skfda.misc.inner_product(fd_basis, fd_basis), res, rtol=1e-5) + skfda.misc.inner_product(fd_basis, fd_basis), res, rtol=1e-4) def test_vector_valued(self): @@ -92,7 +93,7 @@ def test_matrix(self): np.testing.assert_allclose(gram, gram_basis, rtol=1e-2) - gram_pairwise = _pairwise_commutative( + gram_pairwise = _pairwise_symmetric( skfda.misc.inner_product, X, Y) np.testing.assert_allclose(gram, gram_pairwise) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 9c38b5db7..7593bc109 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,76 +1,139 @@ -from skfda import FDataGrid, FDataBasis -from skfda.datasets import make_multimodal_samples -from skfda.exploratory import stats -from skfda.misc.metrics import lp_distance, lp_norm -from skfda.representation.basis import Monomial -import unittest +"""Tests for the metrics module.""" -import scipy.stats.mstats +import unittest import numpy as np +from skfda import FDataBasis, FDataGrid +from skfda.datasets import make_multimodal_samples +from skfda.misc.metrics import ( + l1_norm, + l2_distance, + l2_norm, + linf_norm, + lp_norm, +) +from skfda.representation.basis import Monomial + -class TestLpMetrics(unittest.TestCase): +class TestLp(unittest.TestCase): + """Test the lp norms and distances.""" - def setUp(self): + def setUp(self) -> None: + """Create a few functional data objects.""" grid_points = [1, 2, 3, 4, 5] - self.fd = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], - grid_points=grid_points) + self.fd = FDataGrid( + [ + [2, 3, 4, 5, 6], + [1, 4, 9, 16, 25], + ], + grid_points=grid_points, + ) basis = Monomial(n_basis=3, domain_range=(1, 5)) self.fd_basis = FDataBasis(basis, [[1, 1, 0], [0, 0, 1]]) - self.fd_curve = self.fd.concatenate(self.fd, as_coordinates=True) - self.fd_surface = make_multimodal_samples(n_samples=3, dim_domain=2, - random_state=0) - - def test_lp_norm(self): - - np.testing.assert_allclose(lp_norm(self.fd, p=1), [16., 41.33333333]) - np.testing.assert_allclose(lp_norm(self.fd, p='inf'), [6, 25]) - - def test_lp_norm_curve(self): - - np.testing.assert_allclose(lp_norm(self.fd_curve, p=1, p2=1), - [32., 82.666667]) - np.testing.assert_allclose(lp_norm(self.fd_curve, p='inf', p2='inf'), - [6, 25]) - - def test_lp_norm_surface_inf(self): - np.testing.assert_allclose(lp_norm(self.fd_surface, p='inf').round(5), - [0.99994, 0.99793, 0.99868]) - - def test_lp_norm_surface(self): + self.fd_vector_valued = self.fd.concatenate( + self.fd, + as_coordinates=True, + ) + self.fd_surface = make_multimodal_samples( + n_samples=3, + dim_domain=2, + random_state=0, + ) + + def test_lp_norm_grid(self) -> None: + """Test that the Lp norms work with FDataGrid.""" + np.testing.assert_allclose( + l1_norm(self.fd), + [16.0, 41.33333333], + ) + + np.testing.assert_allclose( + l2_norm(self.fd), + [8.326664, 25.006666], + ) + + np.testing.assert_allclose( + lp_norm(self.fd, p=3), + [6.839904, 22.401268], + ) + + np.testing.assert_allclose( + linf_norm(self.fd), + [6, 25], + ) + + def test_lp_norm_basis(self) -> None: + """Test that the L2 norm works with FDataBasis.""" + np.testing.assert_allclose( + l2_norm(self.fd_basis), + [8.326664, 24.996], + ) + + def test_lp_norm_vector_valued(self) -> None: + """Test that the Lp norms work with vector-valued FDataGrid.""" + np.testing.assert_allclose( + l1_norm(self.fd_vector_valued), + [32.0, 82.666667], + ) + np.testing.assert_allclose( + linf_norm(self.fd_vector_valued), + [6, 25], + ) + + def test_lp_norm_surface_inf(self) -> None: + """Test that the Linf norm works with multidimensional domains.""" + np.testing.assert_allclose( + lp_norm(self.fd_surface, p=np.inf).round(5), + [0.99994, 0.99793, 0.99868], + ) + + def test_lp_norm_surface(self) -> None: + """Test that integration of surfaces has not been implemented.""" # Integration of surfaces not implemented, add test case after # implementation self.assertEqual(lp_norm(self.fd_surface, p=1), NotImplemented) - def test_lp_error_dimensions(self): + def test_lp_error_dimensions(self) -> None: + """Test error on metric between different kind of objects.""" # Case internal arrays with np.testing.assert_raises(ValueError): - lp_distance(self.fd, self.fd_surface) + l2_distance(self.fd, self.fd_surface) with np.testing.assert_raises(ValueError): - lp_distance(self.fd, self.fd_curve) + l2_distance(self.fd, self.fd_vector_valued) with np.testing.assert_raises(ValueError): - lp_distance(self.fd_surface, self.fd_curve) + l2_distance(self.fd_surface, self.fd_vector_valued) - def test_lp_error_domain_ranges(self): + def test_lp_error_domain_ranges(self) -> None: + """Test error on metric between objects with different domains.""" grid_points = [2, 3, 4, 5, 6] - fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], - grid_points=grid_points) + fd2 = FDataGrid( + [ + [2, 3, 4, 5, 6], + [1, 4, 9, 16, 25], + ], + grid_points=grid_points, + ) with np.testing.assert_raises(ValueError): - lp_distance(self.fd, fd2) + l2_distance(self.fd, fd2) - def test_lp_error_grid_points(self): + def test_lp_error_grid_points(self) -> None: + """Test error on metric for FDataGrids with different grid points.""" grid_points = [1, 2, 4, 4.3, 5] - fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], - grid_points=grid_points) + fd2 = FDataGrid( + [ + [2, 3, 4, 5, 6], + [1, 4, 9, 16, 25], + ], + grid_points=grid_points, + ) with np.testing.assert_raises(ValueError): - lp_distance(self.fd, fd2) + l2_distance(self.fd, fd2) if __name__ == '__main__': - print() unittest.main() diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index e699afa22..903a9528d 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -7,7 +7,7 @@ from skfda.datasets import make_multimodal_samples, make_sinusoidal_process from skfda.exploratory.outliers import LocalOutlierFactor # Pending theory from skfda.exploratory.stats import mean -from skfda.misc.metrics import l2_distance, lp_distance, pairwise_distance +from skfda.misc.metrics import PairwiseMetric, l2_distance from skfda.ml.classification import ( KNeighborsClassifier, NearestCentroid, @@ -71,7 +71,7 @@ def test_predict_classifier(self): KNeighborsClassifier(), RadiusNeighborsClassifier(radius=0.1), NearestCentroid(), - NearestCentroid(metric=lp_distance, centroid=mean), + NearestCentroid(metric=l2_distance, centroid=mean), ): neigh.fit(self.X, self.y) @@ -84,7 +84,7 @@ def test_predict_classifier(self): def test_predict_proba_classifier(self): """Tests predict proba for k neighbors classifier.""" - neigh = KNeighborsClassifier(metric=lp_distance) + neigh = KNeighborsClassifier(metric=l2_distance) neigh.fit(self.X, self.y) probs = neigh.predict_proba(self.X) @@ -139,7 +139,7 @@ def test_kneighbors(self): graph = neigh.kneighbors_graph(self.X[:4]) - dist_kneigh = lp_distance(self.X[0], self.X[7]) + dist_kneigh = l2_distance(self.X[0], self.X[7]) np.testing.assert_array_almost_equal(dist[0, 1], dist_kneigh) @@ -167,7 +167,7 @@ def test_radius_neighbors(self): np.testing.assert_array_equal(links[2], np.array([2, 17, 22, 27])) np.testing.assert_array_equal(links[3], np.array([3, 4, 9])) - dist_kneigh = lp_distance(self.X[0], self.X[7]) + dist_kneigh = l2_distance(self.X[0], self.X[7]) np.testing.assert_array_almost_equal(dist[0][1], dist_kneigh) @@ -209,7 +209,7 @@ def test_knn_functional_response_precomputed(self): weights='distance', metric='precomputed', ) - d = pairwise_distance(lp_distance) + d = PairwiseMetric(l2_distance) distances = d(self.X[:4], self.X[:4]) knnr.fit(distances, self.X[:4]) @@ -221,7 +221,7 @@ def test_knn_functional_response_precomputed(self): def test_radius_functional_response(self): knnr = RadiusNeighborsRegressor( - metric=lp_distance, + metric=l2_distance, weights='distance', ) @@ -251,7 +251,7 @@ def test_functional_regression_distance_weights(self): knnr.fit(self.X[:10], self.X[:10]) res = knnr.predict(self.X[11]) - d = pairwise_distance(lp_distance) + d = PairwiseMetric(l2_distance) distances = d(self.X[:10], self.X[11]).flatten() weights = 1 / distances @@ -311,7 +311,7 @@ def test_functional_regressor_exceptions(self): knnr.fit(self.X[:3], self.X[:4]) def test_search_neighbors_precomputed(self): - d = pairwise_distance(lp_distance) + d = PairwiseMetric(l2_distance) distances = d(self.X[:4], self.X[:4]) nn = NearestNeighbors(metric='precomputed', n_neighbors=2) @@ -401,7 +401,7 @@ def test_lof_fit_predict(self): res2 = lof2.fit_predict(self.fd_lof) np.testing.assert_array_equal(expected, res2) - d = pairwise_distance(lp_distance) + d = PairwiseMetric(l2_distance) distances = d(self.fd_lof, self.fd_lof) # With precompute distances diff --git a/tests/test_pandas_fdatabasis.py b/tests/test_pandas_fdatabasis.py index c01f41ff5..ea578939a 100644 --- a/tests/test_pandas_fdatabasis.py +++ b/tests/test_pandas_fdatabasis.py @@ -345,6 +345,11 @@ class TestArithmeticOps(base.BaseArithmeticOpsTests): series_scalar_exc = None + # Bug introduced by https://github.com/pandas-dev/pandas/pull/37132 + @pytest.mark.skip(reason="Unsupported") + def test_arith_frame_with_scalar(self, data, all_arithmetic_operators): + pass + # FDatabasis does not implement division by non constant @pytest.mark.skip(reason="Unsupported") def test_divmod_series_array(self, dtype): diff --git a/tests/test_pandas_fdatagrid.py b/tests/test_pandas_fdatagrid.py index 48cc14f09..b7b9ad27d 100644 --- a/tests/test_pandas_fdatagrid.py +++ b/tests/test_pandas_fdatagrid.py @@ -1,74 +1,89 @@ -import operator +from __future__ import annotations + +from typing import Any, Callable, Generator, NoReturn, Union import numpy as np import pandas import pytest from pandas import Series +from pandas.api.extensions import ExtensionArray, ExtensionDtype from pandas.tests.extension import base import skfda +from skfda.representation.grid import FDataGrid ############################################################################## # Fixtures ############################################################################## @pytest.fixture -def dtype(): - """A fixture providing the ExtensionDtype to validate.""" +def dtype() -> ExtensionDtype: + """Return the ExtensionDtype to validate.""" return skfda.representation.grid.FDataGridDType( grid_points=[ np.arange(10), - np.arange(10) / 10], - dim_codomain=3 + np.arange(10) / 10, + ], + dim_codomain=3, ) @pytest.fixture -def data(): +def data() -> ExtensionArray: """ + Return data. + Length-100 array for this type. * data[0] and data[1] should both be non missing * data[0] and data[1] should not be equal - """ + """ data_matrix = np.arange(1, 100 * 10 * 10 * 3 + 1).reshape(100, 10, 10, 3) grid_points = [ np.arange(10), - np.arange(10) / 10] + np.arange(10) / 10, + ] return skfda.FDataGrid(data_matrix, grid_points=grid_points) @pytest.fixture -def data_for_twos(): - """Length-100 array in which all the elements are two.""" - +def data_for_twos() -> ExtensionArray: + """Return a length-100 array in which all the elements are two.""" data_matrix = np.full( - 100 * 10 * 10 * 3, fill_value=2).reshape(100, 10, 10, 3) + 100 * 10 * 10 * 3, fill_value=2, + ).reshape(100, 10, 10, 3) grid_points = [ np.arange(10), - np.arange(10) / 10] + np.arange(10) / 10, + ] return skfda.FDataGrid(data_matrix, grid_points=grid_points) @pytest.fixture -def data_missing(): - """Length-2 array with [NA, Valid]""" - +def data_missing() -> ExtensionArray: + """Return a length-2 array with [NA, Valid].""" data_matrix = np.arange( - 2 * 10 * 10 * 3, dtype=np.float_).reshape(2, 10, 10, 3) + 2 * 10 * 10 * 3, + dtype=np.float_, + ).reshape(2, 10, 10, 3) data_matrix[0, ...] = np.NaN grid_points = [ np.arange(10), - np.arange(10) / 10] + np.arange(10) / 10, + ] return skfda.FDataGrid(data_matrix, grid_points=grid_points) @pytest.fixture(params=["data", "data_missing"]) -def all_data(request, data, data_missing): - """Parametrized fixture giving 'data' and 'data_missing'""" +def all_data( + request, + data: ExtensionArray, + data_missing: ExtensionArray, +) -> ExtensionArray: + """Return 'data' or 'data_missing'.""" if request.param == "data": return data elif request.param == "data_missing": @@ -76,30 +91,34 @@ def all_data(request, data, data_missing): @pytest.fixture -def data_repeated(data): +def data_repeated( + data: ExtensionArray, +) -> Callable[[int], Generator[ExtensionArray, None, None]]: """ Generate many datasets. - Parameters - ---------- - data : fixture implementing `data` - Returns - ------- - Callable[[int], Generator]: - A callable that takes a `count` argument and - returns a generator yielding `count` datasets. + + Args: + data : Fixture implementing `data` + + Returns: + Callable[[int], Generator]: + A callable that takes a `count` argument and + returns a generator yielding `count` datasets. """ - def gen(count): - for _ in range(count): - yield data + def gen(count: int) -> Generator[ExtensionArray, None, None]: + yield from ( + data for _ in range(count) + ) return gen @pytest.fixture -def data_for_sorting(): +def data_for_sorting() -> NoReturn: """ - Length-3 array with a known sort order. + Return ength-3 array with a known sort order. + This should be three items [B, C, A] with A < B < C """ @@ -107,9 +126,9 @@ def data_for_sorting(): @pytest.fixture -def data_missing_for_sorting(): +def data_missing_for_sorting() -> NoReturn: """ - Length-3 array with a known sort order. + Return length-3 array with a known sort order. This should be three items [B, NA, A] with A < B and NA missing. """ @@ -117,30 +136,37 @@ def data_missing_for_sorting(): @pytest.fixture -def na_cmp(): +def na_cmp() -> Callable[..., bool]: """ Binary operator for comparing NA values. + Should return a function of two arguments that returns True if both arguments are (scalar) NA for your type. By default, uses ``operator.is_`` """ - def isna(x, y): - return ((x is pandas.NA or all(x.isna())) - and (y is pandas.NA or all(y.isna()))) + def isna( + x: Union[pandas.NA, FDataGrid], + y: Union[pandas.NA, FDataGrid], + ) -> bool: + return ( + (x is pandas.NA or all(x.isna())) + and (y is pandas.NA or all(y.isna())) + ) return isna @pytest.fixture -def na_value(): - """The scalar missing value for this type. Default 'None'""" +def na_value() -> pandas.NA: + """Return the scalar missing value for this type. Default 'None'.""" return pandas.NA @pytest.fixture -def data_for_grouping(): +def data_for_grouping() -> NoReturn: """ - Data for factorization, grouping, and unique tests. + Return data for factorization, grouping, and unique tests. + Expected to be like [B, B, NA, NA, A, A, B, C] Where A < B < C and NA is missing """ @@ -148,8 +174,8 @@ def data_for_grouping(): @pytest.fixture(params=[True, False]) -def box_in_series(request): - """Whether to box the data in a Series""" +def box_in_series(request) -> bool: + """Whether to box the data in a Series.""" return request.param @@ -162,32 +188,28 @@ def box_in_series(request): ], ids=["scalar", "list", "series", "object"], ) -def groupby_apply_op(request): - """ - Functions to test groupby.apply(). - """ +def groupby_apply_op(request) -> Callable[[FDataGrid], Any]: + """Functions to test groupby.apply().""" return request.param @pytest.fixture(params=[True, False]) -def as_frame(request): - """ - Boolean fixture to support Series and Series.to_frame() comparison testing. - """ +def as_frame(request) -> bool: + """Whether to support Series and Series.to_frame() comparison testing.""" return request.param @pytest.fixture(params=[True, False]) -def as_series(request): - """ - Boolean fixture to support arr and Series(arr) comparison testing. - """ +def as_series(request) -> bool: + """Boolean fixture to support arr and Series(arr) comparison testing.""" return request.param @pytest.fixture(params=[True, False]) -def use_numpy(request): +def use_numpy(request) -> bool: """ + Compare ExtensionDtype and numpy. + Boolean fixture to support comparison testing of ExtensionDtype array and numpy array. """ @@ -195,8 +217,10 @@ def use_numpy(request): @pytest.fixture(params=["ffill", "bfill"]) -def fillna_method(request): +def fillna_method(request) -> str: """ + Series.fillna parameter fixture. + Parametrized fixture giving method parameters 'ffill' and 'bfill' for Series.fillna(method=) testing. """ @@ -204,10 +228,8 @@ def fillna_method(request): @pytest.fixture(params=[True, False]) -def as_array(request): - """ - Boolean fixture to support ExtensionDtype _from_sequence method testing. - """ +def as_array(request) -> bool: + """Whether to support ExtensionDtype _from_sequence method testing.""" return request.param @@ -230,7 +252,7 @@ def as_array(request): @pytest.fixture(params=_all_arithmetic_operators) -def all_arithmetic_operators(request): +def all_arithmetic_operators(request) -> Callable[..., Any]: """ Fixture for dunder names for common arithmetic operations. """ @@ -240,7 +262,7 @@ def all_arithmetic_operators(request): @pytest.fixture(params=["__eq__", "__ne__", # "__le__", "__lt__", "__ge__", "__gt__" ]) -def all_compare_operators(request): +def all_compare_operators(request) -> Callable[..., Any]: """ Fixture for dunder names for common compare operations """ @@ -277,12 +299,12 @@ class TestCasting(base.BaseCastingTests): # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_astype_str(self): + def test_astype_str(self) -> None: pass # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_astype_string(self): + def test_astype_string(self) -> None: pass @@ -290,12 +312,12 @@ class TestConstructors(base.BaseConstructorsTests): # Does not support scalars which are also ExtensionArrays @pytest.mark.skip(reason="Unsupported") - def test_series_constructor_scalar_with_index(self): + def test_series_constructor_scalar_with_index(self) -> None: pass # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_from_dtype(self): + def test_from_dtype(self) -> None: pass @@ -303,22 +325,25 @@ class TestDtype(base.BaseDtypeTests): # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_construct_from_string_own_name(self): + def test_construct_from_string_own_name(self) -> None: pass # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_is_dtype_from_name(self): + def test_is_dtype_from_name(self) -> None: pass # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_eq_with_str(self): + def test_eq_with_str(self) -> None: pass # Tries to construct dtype from string @pytest.mark.skip(reason="Unsupported") - def test_construct_from_string(self, dtype): + def test_construct_from_string( + self, + dtype: ExtensionDtype, + ) -> None: pass @@ -330,22 +355,32 @@ class TestInterface(base.BaseInterfaceTests): # Does not support scalars which are also array_like @pytest.mark.skip(reason="Unsupported") - def test_array_interface(self): + def test_array_interface(self) -> None: pass # We do not implement setitem @pytest.mark.skip(reason="Unsupported") - def test_copy(self, dtype): + def test_copy( + self, + dtype: ExtensionDtype, + ) -> None: pass # We do not implement setitem @pytest.mark.skip(reason="Unsupported") - def test_view(self, dtype): + def test_view( + self, + dtype: ExtensionDtype, + ) -> None: pass # Pending https://github.com/pandas-dev/pandas/issues/38812 resolution @pytest.mark.skip(reason="Bugged") - def test_contains(self, data, data_missing): + def test_contains( + self, + data: ExtensionArray, + data_missing: ExtensionArray, + ) -> None: pass @@ -353,14 +388,29 @@ class TestArithmeticOps(base.BaseArithmeticOpsTests): series_scalar_exc = None + # Bug introduced by https://github.com/pandas-dev/pandas/pull/37132 + @pytest.mark.skip(reason="Unsupported") + def test_arith_frame_with_scalar( + self, + data: ExtensionArray, + all_arithmetic_operators: Callable[..., Any], + ) -> None: + pass + # Does not convert properly a list of FData to a FData @pytest.mark.skip(reason="Unsupported") - def test_arith_series_with_array(self, dtype): + def test_arith_series_with_array( + self, + dtype: ExtensionDtype, + ) -> None: pass # Does not error on operations @pytest.mark.skip(reason="Unsupported") - def test_error(self, dtype): + def test_error( + self, + dtype: ExtensionDtype, + ) -> None: pass @@ -368,17 +418,30 @@ class TestComparisonOps(base.BaseComparisonOpsTests): # Cannot be compared with 0 @pytest.mark.skip(reason="Unsupported") - def test_compare_scalar(self, data, all_compare_operators): + def test_compare_scalar( + self, + data: ExtensionArray, + all_compare_operators: Callable[..., Any], + ) -> None: pass # Not sure how to pass it. Should it be reimplemented? @pytest.mark.skip(reason="Unsupported") - def test_compare_array(self, data, all_compare_operators): + def test_compare_array( + self, + data: ExtensionArray, + all_compare_operators: Callable[..., Any], + ) -> None: pass class TestNumericReduce(base.BaseNumericReduceTests): - def check_reduce(self, s, op_name, skipna): + def check_reduce( + self, + s: FDataGrid, + op_name: str, + skipna: bool, + ) -> None: result = getattr(s, op_name)(skipna=skipna) assert result.n_samples == 1 diff --git a/tests/test_registration.py b/tests/test_registration.py index 62781ee4b..0398899b3 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -1,21 +1,33 @@ +import unittest + +import numpy as np +from sklearn.exceptions import NotFittedError + from skfda import FDataGrid from skfda._utils import _check_estimator -from skfda.datasets import (make_multimodal_samples, make_multimodal_landmarks, - make_sinusoidal_process) +from skfda.datasets import ( + make_multimodal_landmarks, + make_multimodal_samples, + make_sinusoidal_process, +) from skfda.exploratory.stats import mean from skfda.preprocessing.registration import ( - normalize_warping, invert_warping, landmark_shift_deltas, landmark_shift, - landmark_registration_warping, landmark_registration, ShiftRegistration) + ShiftRegistration, + invert_warping, + landmark_registration, + landmark_registration_warping, + landmark_shift, + landmark_shift_deltas, + normalize_warping, +) from skfda.preprocessing.registration.validation import ( - AmplitudePhaseDecomposition, LeastSquares, - SobolevLeastSquares, PairwiseCorrelation) + AmplitudePhaseDecomposition, + LeastSquares, + PairwiseCorrelation, + SobolevLeastSquares, +) from skfda.representation.basis import Fourier from skfda.representation.interpolation import SplineInterpolation -import unittest - -from sklearn.exceptions import NotFittedError - -import numpy as np class TestWarping(unittest.TestCase): @@ -294,14 +306,17 @@ def test_template(self): np.testing.assert_array_almost_equal(fd_registered_2.data_matrix, fd_registered_4.data_matrix) - def test_restrict_domain(self): + def test_restrict_domain(self) -> None: reg = ShiftRegistration(restrict_domain=True) fd_registered_1 = reg.fit_transform(self.fd) np.testing.assert_array_almost_equal( np.array(fd_registered_1.domain_range).round(3), [[0.022, 0.969]]) - reg2 = ShiftRegistration(restrict_domain=True, template=reg.template_) + reg2 = ShiftRegistration( + restrict_domain=True, + template=reg.template_.copy(domain_range=self.fd.domain_range), + ) fd_registered_2 = reg2.fit_transform(self.fd) np.testing.assert_array_almost_equal( @@ -321,69 +336,68 @@ def test_initial_estimation(self): # Only needed 1 iteration until convergence self.assertEqual(reg.n_iter_, 1) - def test_custom_output_points(self): - reg = ShiftRegistration(output_points=np.linspace(0, 1, 50)) + def test_custom_grid_points(self): + reg = ShiftRegistration(grid_points=np.linspace(0, 1, 50)) reg.fit_transform(self.fd) class TestRegistrationValidation(unittest.TestCase): - """Test shift registration""" + """Test validation functions.""" - def setUp(self): - """Initialization of samples""" + def setUp(self) -> None: + """Initialize the samples.""" self.X = make_sinusoidal_process(error_std=0, random_state=0) self.shift_registration = ShiftRegistration().fit(self.X) - def test_amplitude_phase_score(self): + def test_amplitude_phase_score(self) -> None: + """Test basic usage of AmplitudePhaseDecomposition.""" scorer = AmplitudePhaseDecomposition() score = scorer(self.shift_registration, self.X) - np.testing.assert_allclose(score, 0.972095, rtol=1e-6) + np.testing.assert_allclose(score, 0.971144, rtol=1e-6) - def test_amplitude_phase_score_with_output_points(self): - eval_points = self.X.grid_points[0] - scorer = AmplitudePhaseDecomposition(eval_points=eval_points) - score = scorer(self.shift_registration, self.X) - np.testing.assert_allclose(score, 0.972095, rtol=1e-6) - - def test_amplitude_phase_score_with_basis(self): + def test_amplitude_phase_score_with_basis(self) -> None: + """Test the AmplitudePhaseDecomposition with FDataBasis.""" scorer = AmplitudePhaseDecomposition() X = self.X.to_basis(Fourier()) score = scorer(self.shift_registration, X) - np.testing.assert_allclose(score, 0.995087, rtol=1e-6) - - def test_default_score(self): + np.testing.assert_allclose(score, 0.995086, rtol=1e-6) + def test_default_score(self) -> None: + """Test default score of a registration transformer.""" score = self.shift_registration.score(self.X) - np.testing.assert_allclose(score, 0.972095, rtol=1e-6) + np.testing.assert_allclose(score, 0.971144, rtol=1e-6) - def test_least_squares_score(self): + def test_least_squares_score(self) -> None: + """Test LeastSquares.""" scorer = LeastSquares() score = scorer(self.shift_registration, self.X) - np.testing.assert_allclose(score, 0.795933, rtol=1e-6) + np.testing.assert_allclose(score, 0.953355, rtol=1e-6) - def test_sobolev_least_squares_score(self): + def test_sobolev_least_squares_score(self) -> None: + """Test SobolevLeastSquares.""" scorer = SobolevLeastSquares() score = scorer(self.shift_registration, self.X) - np.testing.assert_allclose(score, 0.76124, rtol=1e-6) + np.testing.assert_allclose(score, 0.923962, rtol=1e-6) - def test_pairwise_correlation(self): + def test_pairwise_correlation(self) -> None: + """Test PairwiseCorrelation.""" scorer = PairwiseCorrelation() score = scorer(self.shift_registration, self.X) np.testing.assert_allclose(score, 1.816228, rtol=1e-6) - def test_mse_decomposition(self): - + def test_mse_decomposition(self) -> None: + """Test obtaining all stats from AmplitudePhaseDecomposition.""" fd = make_multimodal_samples(n_samples=3, random_state=1) landmarks = make_multimodal_landmarks(n_samples=3, random_state=1) landmarks = landmarks.squeeze() warping = landmark_registration_warping(fd, landmarks) fd_registered = fd.compose(warping) - scorer = AmplitudePhaseDecomposition(return_stats=True) - ret = scorer.score_function(fd, fd_registered, warping=warping) - np.testing.assert_allclose(ret.mse_amp, 0.0009866997121476962) - np.testing.assert_allclose(ret.mse_pha, 0.11576935495450151) - np.testing.assert_allclose(ret.r_squared, 0.9915489952877273) - np.testing.assert_allclose(ret.c_r, 0.999999, rtol=1e-6) + scorer = AmplitudePhaseDecomposition() + ret = scorer.stats(fd, fd_registered) + np.testing.assert_allclose(ret.mse_amplitude, 0.0009465483) + np.testing.assert_allclose(ret.mse_phase, 0.1051769136) + np.testing.assert_allclose(ret.r_squared, 0.9910806875) + np.testing.assert_allclose(ret.c_r, 0.9593073773) def test_raises_amplitude_phase(self): scorer = AmplitudePhaseDecomposition() @@ -394,7 +408,7 @@ def test_raises_amplitude_phase(self): # Inconsistent number of functions registered with np.testing.assert_raises(ValueError): - scorer.score_function(self.X, self.X, warping=self.X[:2]) + scorer.score_function(self.X, self.X[:-1]) if __name__ == '__main__': diff --git a/tests/test_regression.py b/tests/test_regression.py index edd661582..9b17891b8 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,11 +1,15 @@ -from skfda.misc.operators import LinearDifferentialOperator -from skfda.misc.regularization import TikhonovRegularization -from skfda.ml.regression import LinearRegression -from skfda.representation.basis import (FDataBasis, Monomial, - Fourier, BSpline) import unittest import numpy as np +from scipy.integrate import cumtrapz + +from skfda.datasets import make_gaussian, make_gaussian_process +from skfda.misc.covariances import Gaussian +from skfda.misc.operators import LinearDifferentialOperator +from skfda.misc.regularization import TikhonovRegularization +from skfda.ml.regression import HistoricalLinearRegression, LinearRegression +from skfda.representation.basis import BSpline, FDataBasis, Fourier, Monomial +from skfda.representation.grid import FDataGrid class TestScalarLinearRegression(unittest.TestCase): @@ -316,6 +320,152 @@ def test_error_weights_negative(self): scalar.fit([x_fd], y, weights) +class TestHistoricalLinearRegression(unittest.TestCase): + """Tests for historical linear regression.""" + + def setUp(self) -> None: + """Generate data according to the model.""" + self.random = np.random.RandomState(1) + + self.n_samples = 50 + self.n_features = 20 + self.intercept = make_gaussian_process( + n_samples=1, + n_features=self.n_features, + cov=Gaussian(length_scale=0.4), + random_state=self.random, + ) + + self.X = make_gaussian_process( + n_samples=self.n_samples, + n_features=self.n_features, + cov=Gaussian(length_scale=0.4), + random_state=self.random, + ) + + self.coefficients = make_gaussian( + n_samples=1, + grid_points=[np.linspace(0, 1, self.n_features)] * 2, + cov=Gaussian(length_scale=1), + random_state=self.random, + ) + + self.X2 = make_gaussian_process( + n_samples=self.n_samples, + n_features=self.n_features, + cov=Gaussian(length_scale=0.4), + random_state=self.random, + ) + + self.coefficients2 = make_gaussian( + n_samples=1, + grid_points=[np.linspace(0, 1, self.n_features)] * 2, + cov=Gaussian(length_scale=1), + random_state=self.random, + ) + + self.create_model() + self.create_vectorial_model() + + def create_model_no_intercept( + self, + X: FDataGrid, + coefficients: FDataGrid, + ) -> FDataGrid: + """Create a functional response according to historical model.""" + integral_body = ( + X.data_matrix[..., 0, np.newaxis] + * coefficients.data_matrix[..., 0] + ) + integral_matrix = cumtrapz( + integral_body, + x=X.grid_points[0], + initial=0, + axis=1, + ) + integral = np.diagonal(integral_matrix, axis1=1, axis2=2) + return X.copy(data_matrix=integral) + + def create_model(self) -> None: + """Create a functional response according to historical model.""" + model_no_intercept = self.create_model_no_intercept( + X=self.X, + coefficients=self.coefficients, + ) + self.y = model_no_intercept + self.intercept + + def create_vectorial_model(self) -> None: + """Create a functional response according to historical model.""" + model_no_intercept = self.create_model_no_intercept( + X=self.X, + coefficients=self.coefficients, + ) + model_no_intercept2 = self.create_model_no_intercept( + X=self.X2, + coefficients=self.coefficients2, + ) + self.y2 = model_no_intercept + model_no_intercept2 + self.intercept + + def test_historical(self) -> None: + """Test historical regression with data following the model.""" + regression = HistoricalLinearRegression(n_intervals=6) + fit_predict_result = regression.fit_predict(self.X, self.y) + predict_result = regression.predict(self.X) + + np.testing.assert_allclose( + predict_result.data_matrix, + fit_predict_result.data_matrix, + ) + + np.testing.assert_allclose( + predict_result.data_matrix, + self.y.data_matrix, + rtol=1e-1, + ) + + np.testing.assert_allclose( + regression.intercept_.data_matrix, + self.intercept.data_matrix, + rtol=1e-3, + ) + + np.testing.assert_allclose( + regression.coef_.data_matrix[0, ..., 0], + np.triu(self.coefficients.data_matrix[0, ..., 0]), + atol=0.3, + rtol=0, + ) + + def test_historical_vectorial(self) -> None: + """Test historical regression with data following the vector model.""" + X = self.X.concatenate(self.X2, as_coordinates=True) + + regression = HistoricalLinearRegression(n_intervals=10) + fit_predict_result = regression.fit_predict(X, self.y2) + predict_result = regression.predict(X) + + np.testing.assert_allclose( + predict_result.data_matrix, + fit_predict_result.data_matrix, + ) + + np.testing.assert_allclose( + predict_result.data_matrix, + self.y2.data_matrix, + atol=1e-1, + rtol=0, + ) + + np.testing.assert_allclose( + regression.intercept_.data_matrix, + self.intercept.data_matrix, + rtol=1e-2, + ) + + # Coefficient matrix not tested as it is probably + # an ill-posed problem + + if __name__ == '__main__': print() unittest.main() diff --git a/tests/test_regularization.py b/tests/test_regularization.py index f9b8b1db9..41f839829 100644 --- a/tests/test_regularization.py +++ b/tests/test_regularization.py @@ -1,26 +1,48 @@ -import skfda -from skfda.misc.operators import LinearDifferentialOperator, gramian_matrix -from skfda.misc.operators._linear_differential_operator import ( - _monomial_evaluate_constant_linear_diff_op) -from skfda.misc.operators._operators import gramian_matrix_numerical -from skfda.misc.regularization import TikhonovRegularization, L2Regularization -from skfda.ml.regression import LinearRegression -from skfda.representation.basis import Constant, Monomial, BSpline, Fourier +"""Test regularization methods.""" +from __future__ import annotations + import unittest import warnings +from typing import Callable, Optional, Sequence, Union +import numpy as np from sklearn.datasets import make_regression from sklearn.linear_model import Ridge from sklearn.model_selection._split import train_test_split -import numpy as np +import skfda +from skfda.misc.operators import LinearDifferentialOperator, gramian_matrix +from skfda.misc.operators._linear_differential_operator import ( + _monomial_evaluate_constant_linear_diff_op, +) +from skfda.misc.operators._operators import gramian_matrix_numerical +from skfda.misc.regularization import L2Regularization, TikhonovRegularization +from skfda.ml.regression import LinearRegression +from skfda.representation.basis import ( + Basis, + BSpline, + Constant, + Fourier, + Monomial, +) +LinearDifferentialOperatorInput = Union[ + int, + Sequence[Union[float, Callable[[np.ndarray], np.ndarray]]], + None, +] -class TestLinearDifferentialOperatorRegularization(unittest.TestCase): - # def setUp(self): could be defined for set up before any test +class TestLinearDifferentialOperatorRegularization(unittest.TestCase): + """Test linear differential operator penalty with different bases.""" - def _test_penalty(self, basis, linear_diff_op, atol=0, result=None): + def _test_penalty( + self, + basis: Basis, + linear_diff_op: LinearDifferentialOperatorInput, + atol: float = 0, + result: Optional[np.ndarray] = None, + ) -> None: operator = LinearDifferentialOperator(linear_diff_op) @@ -30,73 +52,93 @@ def _test_penalty(self, basis, linear_diff_op, atol=0, result=None): np.testing.assert_allclose( penalty, numerical_penalty, - atol=atol + atol=atol, ) if result is not None: np.testing.assert_allclose( penalty, result, - atol=atol + atol=atol, ) - def test_constant_penalty(self): + def test_constant_penalty(self) -> None: + """Test penalty for Constant basis.""" basis = Constant(domain_range=(0, 3)) res = np.array([[12]]) self._test_penalty(basis, linear_diff_op=[2, 3, 4], result=res) - def test_monomial_linear_diff_op(self): + def test_monomial_linear_diff_op(self) -> None: + """Test directly the penalty for Monomial basis.""" n_basis = 5 basis = Monomial(n_basis=n_basis) linear_diff_op = [3] - res = np.array([[0., 0., 0., 0., 3.], - [0., 0., 0., 3., 0.], - [0., 0., 3., 0., 0.], - [0., 3., 0., 0., 0.], - [3., 0., 0., 0., 0.]]) + res = np.array([ + [0, 0, 0, 0, 3], + [0, 0, 0, 3, 0], + [0, 0, 3, 0, 0], + [0, 3, 0, 0, 0], + [3, 0, 0, 0, 0], + ]) np.testing.assert_allclose( - _monomial_evaluate_constant_linear_diff_op(basis, linear_diff_op), - res + _monomial_evaluate_constant_linear_diff_op( + basis, + np.array(linear_diff_op), + ), + res, ) linear_diff_op = [3, 2] - res = np.array([[0., 0., 0., 0., 3.], - [0., 0., 0., 3., 2.], - [0., 0., 3., 4., 0.], - [0., 3., 6., 0., 0.], - [3., 8., 0., 0., 0.]]) + res = np.array([ + [0, 0, 0, 0, 3], + [0, 0, 0, 3, 2], + [0, 0, 3, 4, 0], + [0, 3, 6, 0, 0], + [3, 8, 0, 0, 0], + ]) np.testing.assert_allclose( - _monomial_evaluate_constant_linear_diff_op(basis, linear_diff_op), - res + _monomial_evaluate_constant_linear_diff_op( + basis, + np.array(linear_diff_op), + ), + res, ) linear_diff_op = [3, 0, 5] - res = np.array([[0., 0., 0., 0., 3.], - [0., 0., 0., 3., 0.], - [0., 0., 3., 0., 10.], - [0., 3., 0., 30., 0.], - [3., 0., 60., 0., 0.]]) + res = np.array([ + [0, 0, 0, 0, 3], + [0, 0, 0, 3, 0], + [0, 0, 3, 0, 10], + [0, 3, 0, 30, 0], + [3, 0, 60, 0, 0], + ]) np.testing.assert_allclose( - _monomial_evaluate_constant_linear_diff_op(basis, linear_diff_op), - res + _monomial_evaluate_constant_linear_diff_op( + basis, + np.array(linear_diff_op), + ), + res, ) - def test_monomial_penalty(self): + def test_monomial_penalty(self) -> None: + """Test penalty for Monomial basis.""" basis = Monomial(n_basis=5, domain_range=(0, 3)) # Theorethical result - res = np.array([[0., 0., 0., 0., 0.], - [0., 0., 0., 0., 0.], - [0., 0., 12., 54., 216.], - [0., 0., 54., 324., 1458.], - [0., 0., 216., 1458., 6998.4]]) + res = np.array([ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 12, 54, 216], + [0, 0, 54, 324, 1458], + [0, 0, 216, 1458, 6998.4], + ]) self._test_penalty(basis, linear_diff_op=2, result=res) @@ -108,14 +150,17 @@ def test_monomial_penalty(self): self._test_penalty(basis, linear_diff_op=1) self._test_penalty(basis, linear_diff_op=27) - def test_fourier_penalty(self): + def test_fourier_penalty(self) -> None: + """Test penalty for Fourier basis.""" basis = Fourier(n_basis=5) - res = np.array([[0., 0., 0., 0., 0.], - [0., 1558.55, 0., 0., 0.], - [0., 0., 1558.55, 0., 0.], - [0., 0., 0., 24936.73, 0.], - [0., 0., 0., 0., 24936.73]]) + res = np.array([ + [0, 0, 0, 0, 0], + [0, 1558.55, 0, 0, 0], + [0, 0, 1558.55, 0, 0], + [0, 0, 0, 24936.73, 0], + [0, 0, 0, 0, 24936.73], + ]) # Those comparisons require atol as there are zeros involved self._test_penalty(basis, linear_diff_op=2, atol=0.01, result=res) @@ -127,14 +172,17 @@ def test_fourier_penalty(self): self._test_penalty(basis, linear_diff_op=1, atol=1e-7) self._test_penalty(basis, linear_diff_op=3, atol=1e-7) - def test_bspline_penalty(self): + def test_bspline_penalty(self) -> None: + """Test penalty for BSpline basis.""" basis = BSpline(n_basis=5) - res = np.array([[96., -132., 24., 12., 0.], - [-132., 192., -48., -24., 12.], - [24., -48., 48., -48., 24.], - [12., -24., -48., 192., -132.], - [0., 12., 24., -132., 96.]]) + res = np.array([ + [96, -132, 24, 12, 0], + [-132, 192, -48, -24, 12], + [24, -48, 48, -48, 24], + [12, -24, -48, 192, -132], + [0, 12, 24, -132, 96], + ]) self._test_penalty(basis, linear_diff_op=2, result=res) @@ -149,14 +197,17 @@ def test_bspline_penalty(self): basis = BSpline(n_basis=16, order=8) self._test_penalty(basis, linear_diff_op=0, atol=1e-7) - def test_bspline_penalty_special_case(self): + def test_bspline_penalty_special_case(self) -> None: + """Test for behavior like in issue #185.""" basis = BSpline(n_basis=5) - res = np.array([[1152., -2016., 1152., -288., 0.], - [-2016., 3600., -2304., 1008., -288.], - [1152., -2304., 2304., -2304., 1152.], - [-288., 1008., -2304., 3600., -2016.], - [0., -288., 1152., -2016., 1152.]]) + res = np.array([ + [1152, -2016, 1152, -288, 0], + [-2016, 3600, -2304, 1008, -288], + [1152, -2304, 2304, -2304, 1152], + [-288, 1008, -2304, 3600, -2016], + [0, -288, 1152, -2016, 1152], + ]) operator = LinearDifferentialOperator(basis.order - 1) penalty = gramian_matrix(operator, basis) @@ -164,63 +215,81 @@ def test_bspline_penalty_special_case(self): np.testing.assert_allclose( penalty, - res + res, ) np.testing.assert_allclose( numerical_penalty, - res + res, ) class TestEndpointsDifferenceRegularization(unittest.TestCase): + """Test regularization with a callable.""" - def test_basis_conversion(self): - + def test_basis_conversion(self) -> None: + """Test that in basis smoothing.""" data_matrix = np.linspace([0, 1, 2, 3], [1, 2, 3, 4], 100) fd = skfda.FDataGrid(data_matrix.T) smoother = skfda.preprocessing.smoothing.BasisSmoother( basis=skfda.representation.basis.BSpline( - n_basis=10, domain_range=fd.domain_range), + n_basis=10, + domain_range=fd.domain_range, + ), regularization=TikhonovRegularization( - lambda x: x(1)[:, 0] - x(0)[:, 0]), - smoothing_parameter=10000) + lambda x: x(1)[:, 0] - x(0)[:, 0], + ), + smoothing_parameter=10000, + ) fd_basis = smoother.fit_transform(fd) np.testing.assert_allclose( fd_basis(0), fd_basis(1), - atol=0.001 + atol=0.001, ) class TestL2Regularization(unittest.TestCase): + """Test the L2 regularization.""" - def test_multivariate(self): + def test_multivariate(self) -> None: + """Test that it works with multivariate inputs.""" - def ignore_scalar_warning(): + def ignore_scalar_warning() -> None: # noqa: WPS430 warnings.filterwarnings( - "ignore", category=UserWarning, - message="All the covariates are scalar.") + "ignore", + category=UserWarning, + message="All the covariates are scalar.", + ) - X, y = make_regression(n_samples=20, n_features=10, - random_state=1, bias=3.5) + X, y = make_regression( + n_samples=20, + n_features=10, + random_state=1, + bias=3.5, + ) X_train, X_test, y_train, _ = train_test_split( - X, y, random_state=2) + X, + y, + random_state=2, + ) - for regularization_parameter in [0, 1, 10, 100]: + for regularization_parameter in (0, 1, 10, 100): with self.subTest( - regularization_parameter=regularization_parameter): + regularization_parameter=regularization_parameter, + ): sklearn_l2 = Ridge(alpha=regularization_parameter) skfda_l2 = LinearRegression( regularization=L2Regularization( - regularization_parameter=regularization_parameter), + regularization_parameter=regularization_parameter, + ), ) sklearn_l2.fit(X_train, y_train) @@ -234,10 +303,16 @@ def ignore_scalar_warning(): skfda_y_pred = skfda_l2.predict(X_test) np.testing.assert_allclose( - sklearn_l2.coef_, skfda_l2.coef_[0]) + sklearn_l2.coef_, + skfda_l2.coef_[0], + ) np.testing.assert_allclose( - sklearn_l2.intercept_, skfda_l2.intercept_) + sklearn_l2.intercept_, + skfda_l2.intercept_, + ) np.testing.assert_allclose( - sklearn_y_pred, skfda_y_pred) + sklearn_y_pred, + skfda_y_pred, + ) diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 1061bc02a..e3f9b0bb3 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -1,17 +1,17 @@ -import skfda -from skfda._utils import _check_estimator -from skfda.misc.operators import LinearDifferentialOperator -from skfda.misc.regularization import TikhonovRegularization -from skfda.representation.basis import BSpline, Monomial -from skfda.representation.grid import FDataGrid import unittest +import numpy as np import sklearn -import numpy as np +import skfda import skfda.preprocessing.smoothing as smoothing import skfda.preprocessing.smoothing.kernel_smoothers as kernel_smoothers import skfda.preprocessing.smoothing.validation as validation +from skfda._utils import _check_estimator +from skfda.misc.operators import LinearDifferentialOperator +from skfda.misc.regularization import TikhonovRegularization +from skfda.representation.basis import BSpline, Monomial +from skfda.representation.grid import FDataGrid class TestSklearnEstimators(unittest.TestCase): @@ -129,7 +129,7 @@ def test_monomial_smoothing(self): fd_basis.coefficients.round(2), np.array([[0.61, -0.88, 0.06, 0.02]])) - def test_vector_valued_smoothing(self): + def test_vector_valued_smoothing(self) -> None: X, _ = skfda.datasets.fetch_weather(return_X_y=True) basis_dim = skfda.representation.basis.Fourier( @@ -138,7 +138,7 @@ def test_vector_valued_smoothing(self): [basis_dim] * 2 ) - for method in smoothing.BasisSmoother.SolverMethod: + for method in ('cholesky', 'qr', 'svd'): with self.subTest(method=method): basis_smoother = smoothing.BasisSmoother( diff --git a/tests/test_stats.py b/tests/test_stats.py index 234aa909c..7c0d8e465 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,111 +1,146 @@ -import skfda -from skfda.exploratory.stats import geometric_median import unittest + import numpy as np +import skfda +from skfda.exploratory.stats import geometric_median, modified_epigraph_index + class TestGeometricMedian(unittest.TestCase): + """Test the behavior of the geometric median.""" - def test_R_comparison(self): + def test_R_comparison(self) -> None: """ - Compare the results obtained using a real-world dataset with those in - R (Gmedian package). + Compare the results with real-world dataset with those in R. - """ + The R package used is the Gmedian package. + """ X, _ = skfda.datasets.fetch_tecator(return_X_y=True) - r_res = [2.74083, 2.742715, 2.744627, 2.74659, 2.748656, - 2.750879, 2.753307, 2.755984, 2.758927, 2.762182, - 2.765724, 2.76957, 2.773756, 2.778333, 2.783346, - 2.788818, 2.794758, 2.801225, 2.808233, 2.815714, - 2.82351, 2.831355, 2.838997, 2.846298, 2.853295, - 2.860186, 2.867332, 2.875107, 2.883778, 2.893419, - 2.903851, 2.914717, 2.925698, 2.936765, 2.948293, - 2.960908, 2.97526, 2.991206, 3.008222, 3.02552, - 3.042172, 3.057356, 3.070666, 3.082351, 3.093396, - 3.105338, 3.119946, 3.139307, 3.164418, 3.196014, - 3.234248, 3.278306, 3.326051, 3.374015, 3.418148, - 3.455051, 3.483095, 3.502789, 3.515961, 3.524557, - 3.530135, 3.53364, 3.535369, 3.535305, 3.533326, - 3.529343, 3.523357, 3.51548, 3.5059, 3.494807, - 3.482358, 3.468695, 3.453939, 3.438202, 3.421574, - 3.404169, 3.386148, 3.367751, 3.349166, 3.330441, - 3.311532, 3.292318, 3.272683, 3.252482, 3.23157, - 3.2099, 3.187632, 3.165129, 3.14282, 3.121008, - 3.099793, 3.079092, 3.058772, 3.038755, 3.019038, - 2.99963, 2.980476, 2.961467, 2.94252, 2.923682] + r_res = [ # noqa: WPS317 + 2.74083, 2.742715, 2.744627, 2.74659, 2.748656, + 2.750879, 2.753307, 2.755984, 2.758927, 2.762182, + 2.765724, 2.76957, 2.773756, 2.778333, 2.783346, + 2.788818, 2.794758, 2.801225, 2.808233, 2.815714, + 2.82351, 2.831355, 2.838997, 2.846298, 2.853295, + 2.860186, 2.867332, 2.875107, 2.883778, 2.893419, + 2.903851, 2.914717, 2.925698, 2.936765, 2.948293, + 2.960908, 2.97526, 2.991206, 3.008222, 3.02552, + 3.042172, 3.057356, 3.070666, 3.082351, 3.093396, + 3.105338, 3.119946, 3.139307, 3.164418, 3.196014, + 3.234248, 3.278306, 3.326051, 3.374015, 3.418148, + 3.455051, 3.483095, 3.502789, 3.515961, 3.524557, + 3.530135, 3.53364, 3.535369, 3.535305, 3.533326, + 3.529343, 3.523357, 3.51548, 3.5059, 3.494807, + 3.482358, 3.468695, 3.453939, 3.438202, 3.421574, + 3.404169, 3.386148, 3.367751, 3.349166, 3.330441, + 3.311532, 3.292318, 3.272683, 3.252482, 3.23157, + 3.2099, 3.187632, 3.165129, 3.14282, 3.121008, + 3.099793, 3.079092, 3.058772, 3.038755, 3.019038, + 2.99963, 2.980476, 2.961467, 2.94252, 2.923682, + ] median_multivariate = geometric_median(X.data_matrix[..., 0]) median = geometric_median(X) np.testing.assert_allclose( - median.data_matrix[0, :, 0], median_multivariate, rtol=1e-4) + median.data_matrix[0, :, 0], + median_multivariate, + rtol=1e-4, + ) np.testing.assert_allclose(median_multivariate, r_res, rtol=1e-6) - def test_big(self): - + def test_big(self) -> None: + """Test a bigger dataset.""" X, _ = skfda.datasets.fetch_phoneme(return_X_y=True) - res = np.array( - [10.87814495, 12.10539654, 15.19841961, 16.29929599, 15.52206033, - 15.35123923, 16.44119775, 16.92255038, 16.70263134, 16.62235371, - 16.76616863, 16.80691414, 16.67460045, 16.64628944, 16.60898231, - 16.64735698, 16.7749517, 16.84533289, 16.8134475, 16.69540395, - 16.56083649, 16.3716527, 16.13744993, 15.95246457, 15.78934047, - 15.64383354, 15.55120344, 15.4363593, 15.36998848, 15.35300094, - 15.23606121, 15.16001392, 15.07326127, 14.92863818, 14.77405828, - 14.63772985, 14.4496911, 14.22752646, 14.07162908, 13.90989422, - 13.68979176, 13.53664058, 13.45465055, 13.40192835, 13.39111557, - 13.32592256, 13.26068118, 13.2314264, 13.29364741, 13.30700552, - 13.30579737, 13.35277966, 13.36572257, 13.45244228, 13.50615096, - 13.54872786, 13.65412519, 13.74737364, 13.79203753, 13.87827636, - 13.97728725, 14.06989886, 14.09950082, 14.13697733, 14.18414727, - 14.1914785, 14.17973283, 14.19655855, 14.20551814, 14.23059727, - 14.23195262, 14.21091905, 14.22234481, 14.17687285, 14.1732165, - 14.13488535, 14.11564007, 14.0296303, 13.99540104, 13.9383672, - 13.85056848, 13.73195466, 13.66840843, 13.64387247, 13.52972191, - 13.43092629, 13.37470213, 13.31847522, 13.21687255, 13.15170299, - 13.15372387, 13.1059763, 13.09445287, 13.09041529, 13.11710243, - 13.14386673, 13.22359963, 13.27466107, 13.31319886, 13.34650331, - 13.45574711, 13.50415149, 13.53131719, 13.58150982, 13.65962685, - 13.63699657, 13.61248827, 13.60584663, 13.61072488, 13.54361538, - 13.48274699, 13.39589291, 13.33557961, 13.27237689, 13.15525989, - 13.0201153, 12.92930916, 12.81669859, 12.67134652, 12.58933066, - 12.48431933, 12.35395795, 12.23358723, 12.1604567, 12.02565859, - 11.92888167, 11.81510299, 11.74115444, 11.62986853, 11.51119027, - 11.41922977, 11.32781545, 11.23709771, 11.1553455, 11.06238304, - 10.97654662, 10.89217886, 10.837813, 10.76259305, 10.74123747, - 10.63519376, 10.58236217, 10.50270085, 10.43664285, 10.36198002, - 10.29128265, 10.27590625, 10.21337539, 10.14368936, 10.11450364, - 10.12276595, 10.0811153, 10.03603621, 10.00381717, 9.94299925, - 9.91830306, 9.90583771, 9.87254886, 9.84294024, 9.85472138, - 9.82047669, 9.8222713, 9.82272407, 9.78949033, 9.78038714, - 9.78720474, 9.81027704, 9.77565195, 9.80675363, 9.77084177, - 9.75289156, 9.75404079, 9.72316608, 9.7325137, 9.70562447, - 9.74528393, 9.70416261, 9.67298074, 9.6888954, 9.6765554, - 9.62346413, 9.65547732, 9.59897653, 9.64655533, 9.57719677, - 9.52660027, 9.54591084, 9.5389796, 9.53577489, 9.50843709, - 9.4889757, 9.46656255, 9.46875593, 9.48179707, 9.44946697, - 9.4798432, 9.46992684, 9.47672347, 9.50141949, 9.45946886, - 9.48043777, 9.49121177, 9.48771047, 9.51135703, 9.5309805, - 9.52914508, 9.54184114, 9.49902134, 9.5184432, 9.48091512, - 9.4951481, 9.51101019, 9.49815911, 9.48404411, 9.45754481, - 9.43717866, 9.38444679, 9.39625792, 9.38149371, 9.40279467, - 9.37378114, 9.31453485, 9.29494997, 9.30214391, 9.24839539, - 9.25834154, 9.24655115, 9.25298293, 9.22182526, 9.18142295, - 9.16692765, 9.1253291, 9.17396507, 9.11561516, 9.13792622, - 9.14151424, 9.10477211, 9.13132802, 9.10557653, 9.10442614, - 9.09571574, 9.13986784, 9.08555206, 9.11363748, 9.14300157, - 9.13020252, 9.15901185, 9.15329127, 9.19107506, 9.19507704, - 9.16421159, 9.18975673, 9.14399055, 9.15376256, 9.17409705, - 8.50360777]) + res = np.array([ # noqa: WPS317 + 10.87814495, 12.10539654, 15.19841961, 16.29929599, 15.52206033, + 15.35123923, 16.44119775, 16.92255038, 16.70263134, 16.62235371, + 16.76616863, 16.80691414, 16.67460045, 16.64628944, 16.60898231, + 16.64735698, 16.7749517, 16.84533289, 16.8134475, 16.69540395, + 16.56083649, 16.3716527, 16.13744993, 15.95246457, 15.78934047, + 15.64383354, 15.55120344, 15.4363593, 15.36998848, 15.35300094, + 15.23606121, 15.16001392, 15.07326127, 14.92863818, 14.77405828, + 14.63772985, 14.4496911, 14.22752646, 14.07162908, 13.90989422, + 13.68979176, 13.53664058, 13.45465055, 13.40192835, 13.39111557, + 13.32592256, 13.26068118, 13.2314264, 13.29364741, 13.30700552, + 13.30579737, 13.35277966, 13.36572257, 13.45244228, 13.50615096, + 13.54872786, 13.65412519, 13.74737364, 13.79203753, 13.87827636, + 13.97728725, 14.06989886, 14.09950082, 14.13697733, 14.18414727, + 14.1914785, 14.17973283, 14.19655855, 14.20551814, 14.23059727, + 14.23195262, 14.21091905, 14.22234481, 14.17687285, 14.1732165, + 14.13488535, 14.11564007, 14.0296303, 13.99540104, 13.9383672, + 13.85056848, 13.73195466, 13.66840843, 13.64387247, 13.52972191, + 13.43092629, 13.37470213, 13.31847522, 13.21687255, 13.15170299, + 13.15372387, 13.1059763, 13.09445287, 13.09041529, 13.11710243, + 13.14386673, 13.22359963, 13.27466107, 13.31319886, 13.34650331, + 13.45574711, 13.50415149, 13.53131719, 13.58150982, 13.65962685, + 13.63699657, 13.61248827, 13.60584663, 13.61072488, 13.54361538, + 13.48274699, 13.39589291, 13.33557961, 13.27237689, 13.15525989, + 13.0201153, 12.92930916, 12.81669859, 12.67134652, 12.58933066, + 12.48431933, 12.35395795, 12.23358723, 12.1604567, 12.02565859, + 11.92888167, 11.81510299, 11.74115444, 11.62986853, 11.51119027, + 11.41922977, 11.32781545, 11.23709771, 11.1553455, 11.06238304, + 10.97654662, 10.89217886, 10.837813, 10.76259305, 10.74123747, + 10.63519376, 10.58236217, 10.50270085, 10.43664285, 10.36198002, + 10.29128265, 10.27590625, 10.21337539, 10.14368936, 10.11450364, + 10.12276595, 10.0811153, 10.03603621, 10.00381717, 9.94299925, + 9.91830306, 9.90583771, 9.87254886, 9.84294024, 9.85472138, + 9.82047669, 9.8222713, 9.82272407, 9.78949033, 9.78038714, + 9.78720474, 9.81027704, 9.77565195, 9.80675363, 9.77084177, + 9.75289156, 9.75404079, 9.72316608, 9.7325137, 9.70562447, + 9.74528393, 9.70416261, 9.67298074, 9.6888954, 9.6765554, + 9.62346413, 9.65547732, 9.59897653, 9.64655533, 9.57719677, + 9.52660027, 9.54591084, 9.5389796, 9.53577489, 9.50843709, + 9.4889757, 9.46656255, 9.46875593, 9.48179707, 9.44946697, + 9.4798432, 9.46992684, 9.47672347, 9.50141949, 9.45946886, + 9.48043777, 9.49121177, 9.48771047, 9.51135703, 9.5309805, + 9.52914508, 9.54184114, 9.49902134, 9.5184432, 9.48091512, + 9.4951481, 9.51101019, 9.49815911, 9.48404411, 9.45754481, + 9.43717866, 9.38444679, 9.39625792, 9.38149371, 9.40279467, + 9.37378114, 9.31453485, 9.29494997, 9.30214391, 9.24839539, + 9.25834154, 9.24655115, 9.25298293, 9.22182526, 9.18142295, + 9.16692765, 9.1253291, 9.17396507, 9.11561516, 9.13792622, + 9.14151424, 9.10477211, 9.13132802, 9.10557653, 9.10442614, + 9.09571574, 9.13986784, 9.08555206, 9.11363748, 9.14300157, + 9.13020252, 9.15901185, 9.15329127, 9.19107506, 9.19507704, + 9.16421159, 9.18975673, 9.14399055, 9.15376256, 9.17409705, + 8.50360777, + ]) median_multivariate = geometric_median(X.data_matrix[..., 0]) median = geometric_median(X) np.testing.assert_allclose( - median.data_matrix[0, :, 0], median_multivariate, rtol=1e-2) + median.data_matrix[0, :, 0], + median_multivariate, + rtol=1e-2, + ) np.testing.assert_allclose(median_multivariate, res, rtol=1e-6) + + +class TestMEI(unittest.TestCase): + """Test modified epigraph index.""" + + def test_mei(self) -> None: + """Test modified epigraph index.""" + fd, _ = skfda.datasets.fetch_weather(return_X_y=True) + fd_temperatures = fd.coordinates[0] + mei = modified_epigraph_index(fd_temperatures) + np.testing.assert_allclose( + mei, + np.array([ # noqa: WPS317 + 0.46272668, 0.27840835, 0.36268754, 0.27908676, 0.36112198, + 0.30802348, 0.82969341, 0.45904762, 0.53907371, 0.38799739, + 0.41283757, 0.20420091, 0.23564253, 0.14737117, 0.14379648, + 0.54035225, 0.43459883, 0.6378604, 0.86964123, 0.4421396, + 0.58906719, 0.75561644, 0.54982387, 0.46095238, 0.09969993, + 0.13166341, 0.18776256, 0.4831833, 0.36816699, 0.72962818, + 0.80313112, 0.79934768, 0.90643183, 0.90139596, 0.9685062, + ]), + rtol=1e-5, + ) diff --git a/tutorial/README.txt b/tutorial/README.txt new file mode 100644 index 000000000..57e93b2fa --- /dev/null +++ b/tutorial/README.txt @@ -0,0 +1,4 @@ +Tutorial +======== + +Step by step guide on how to use the package. \ No newline at end of file diff --git a/tutorial/__init__.py b/tutorial/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tutorial/plot_basis_representation.py b/tutorial/plot_basis_representation.py new file mode 100644 index 000000000..ac85e8adf --- /dev/null +++ b/tutorial/plot_basis_representation.py @@ -0,0 +1,466 @@ +""" +Basis representation +==================== + +In this section, we will introduce the basis representation of +functional data. This is a very useful representation for functions that +belong (or can be reasonably projected) to the space spanned by a finite set +of basis functions. + +.. Disable isort + isort:skip_file + +""" + +# Author: Carlos Ramos Carreño +# License: MIT +# +# sphinx_gallery_thumbnail_number = 7 + +############################################################################## +# Functions and vector spaces +# --------------------------- +# +# Functions, which are the objects of study of :term:`FDA`, can be added and +# multiplied by scalars, and these operations verify the necessary properties +# to consider these functions as vectors in a vector space. +# +# The :class:`~skfda.representation.grid.FDataGrid` objects that are used to +# represent functional observations in scikit-fda also support these +# operations. + +############################################################################## +# In order to show these operations, we create the first FDatagrid and plot +# it. + +import numpy as np +import skfda + +t = np.linspace(0, 1, 100) + +fd = skfda.FDataGrid( + data_matrix=[ + np.sin(6 * t), # First function + 2 * t, # Second function + ], + grid_points=t, +) + +fd.plot() + +############################################################################## +# Functions can be multiplied by an scalar. This only changes the scale of +# the functions, but not their shape. + +scalar_mul = 3 * fd + +scalar_mul.plot() + +############################################################################## +# We need two objects to show the sum. Thus we create a second FDatagrid and +# plot it. + +fd2 = skfda.FDataGrid( + data_matrix=[ + 3 * t**2, # First function + np.log(t), # Second function + ], + grid_points=t, +) + +fd2.plot() + +############################################################################## +# We can now plot the sum of both :class:`~skfda.representation.grid.FDataGrid` +# objects. + +fd_sum = fd + fd2 + +fd_sum.plot() + +############################################################################## +# Infinite (Schauder) basis +# ------------------------- +# +# Some functional topological vector spaces admit a Schauder basis. This is +# a sequence of functions :math:`\Phi = \{\phi_i\}_{i=1}^{\infty}` so that +# for every function :math:`x` in the space exists a sequence of scalars +# :math:`\{a_i\}_{i=1}^{\infty}` such that +# +# .. math:: +# x(t) = \sum_{i=1}^{\infty} a_i \phi_i(t) +# +# where the convergence of this series is with respect to the vector space +# topology. +# +# If you know that your functions of interest belong to one of these vector +# spaces, it may be interesting to express your functions in a basis. +# As computers have limited memory and computation resources, it is not +# possible to obtain the infinite basis expansion. Instead, one typically +# truncates the expansion to a few basis functions, which are enough to +# approximate your observations with a certain degree of accuracy. This +# truncation also has the effect of smoothing the data, as less important +# variations, such as noise, are eliminated in the process. Moreover, as basis +# are truncated, the vector space generated by the truncated set of basis +# functions is different to the original space, and also different between +# different basis families. Thus, the choice of basis matters, even if +# originally they would have generated the same space. +# +# In scikit-fda, functions expressed as a basis expansion can be represented +# using the class :class:`~skfda.representation.basis.FDataBasis`. The main +# attributes of objects of this class are ``basis``, an object representing a +# basis family of functions, and ``coefficients``, a matrix with the scalar +# coefficients of the functions in the basis. + +############################################################################## +# As an example, we can create the following function, which is expressed in +# a truncated monomial basis (and thus it is a polynomial): +# +# .. math:: +# x(t) = 3 + 2x - 4x^2 + x^3 + +basis = skfda.representation.basis.Monomial( + n_basis=4, + domain_range=(-10, 10), +) + +fd_basis = skfda.FDataBasis( + basis=basis, + coefficients=[ + [3, 2, -4, 1], # First (and unique) observation + ], +) + +fd_basis.plot() + +############################################################################## +# Conversion between FDataGrid and FDataBasis +# ------------------------------------------- +# +# It is possible to convert between functions in discretized form (class +# :class:`~skfda.representation.grid.FDataGrid`) and basis expansion form ( +# class :class:`~skfda.representation.basis.FDataBasis`). In order to convert +# :class:`~skfda.representation.grid.FDataGrid` objects to a basis +# representation you will need to call the method ``to_basis``, passing the +# desired basis as an argument. The functions will then be projected to the +# functional basis, solving a least squares problem in order to find the +# optimal coefficients of the expansion. In order to convert a +# :class:`~skfda.representation.basis.FDataBasis` to a discretized +# representation you should call the method ``to_grid``. This method evaluates +# the functions in a grid that can be supplied as an argument in order to +# obtain the values of the discretized representation. + +############################################################################## +# We now can see how the number of basis functions affect the basis expansion +# representation of a few observations taken from a real-world dataset. You +# can see that as more basis functions are used, the basis representation +# provides a better representation of the real data. + +import matplotlib.pyplot as plt + +max_basis = 9 + +X, y = skfda.datasets.fetch_phoneme(return_X_y=True) + +# Select only the first 5 samples +X = X[:5] + +X.plot() + +fig, axes = plt.subplots(nrows=3, ncols=3) + +for n_basis in range(1, max_basis + 1): + basis = skfda.representation.basis.Monomial(n_basis=n_basis) + X_basis = X.to_basis(basis) + + ax = axes.ravel()[n_basis - 1] + fig = X_basis.plot(ax=ax) + ax.set_title(f"{n_basis} basis functions") + +fig.tight_layout() + +############################################################################## +# List of available basis functions +# --------------------------------- +# +# In this section we will provide a list of the available basis in scikit-fda. +# As explained before, the basis family is important when the basis expansion +# is truncated (which always happens in order to represent it in a computer). +# Thus, it is recommended to take a look at the available basis in order to +# pick one that provides the best representation of the original data. + +############################################################################## +# First we will load a dataset to test the basis representations. + +X, y = skfda.datasets.fetch_phoneme(return_X_y=True) + +# Select only the first 5 samples +X = X[:5] + +X.plot() + +############################################################################## +# Monomial basis +# ^^^^^^^^^^^^^^ +# +# The monomial basis (class :class:`~skfda.representation.basis.Monomial`) is +# probably one of the simpler and more well-known basis +# of functions. Often Taylor and McLaurin series are explained in the very +# first courses of Science and Engineering degrees, and students are familiar +# with polynomials since much before. Thus, the monomial basis is useful for +# teaching purposes (and that is why we have used it in the examples). It is +# also very useful for testing purposes, as it easy to manually derive the +# expected results of operations involving this basis. +# +# As a basis for functional data analysis, however, it has several issues that +# usually make preferrable to use other basis instead. First, the usual basis +# :math:`\{1, x, x^2, x^3, \ldots\}` is not orthogonal under the standard +# inner product in :math:`L^2`, that is :math:`\langle x, y \rangle = +# \int_{\mathcal{T}} x(t) y(t) dt`. This inhibits some +# performance optimizations that are available for operations that require +# inner products. It is possible to find an orthogonal basis of polynomials, +# but it will not be as easy to understand, losing many of its advantages. +# Another problems with this basis are the necessity of a large +# number of basis functions to express local features, the bad behaviour at +# the extremes of the function and the fact that the derivatives of the basis +# expansion are not good approximations of the derivatives of the original +# data, as high order polynomials tend to have very large oscillations. + +############################################################################## +# Here we show the first five elements of the monomial basis. + +basis = skfda.representation.basis.Monomial(n_basis=5) +basis.plot() + +############################################################################## +# We now show how the previous observations are represented using the first +# five elements of this basis. + +X_basis = X.to_basis(basis) +X_basis.plot() + +############################################################################## +# Fourier basis +# ^^^^^^^^^^^^^^ +# +# Probably the second most well known series expansion for staticians, +# engineers, physicists and mathematicians is the Fourier series. The Fourier +# basis (class :class:`~skfda.representation.basis.Fourier`) consist on a +# constant term plus sines and cosines of varying frequency, +# all of them normalized to unit (:math:`L^2`) norm. +# This basis is a good choice for periodic functions (as a function +# expressed in this basis has the same value at the beginning and at the end +# of its domain interval if it has the same lenght as the period +# :math:`\omega`. Moreover, in this case the functions are orthonormal (that +# is why the basis used are normalized). +# +# This basis is specially indicated for functions without strong local +# features and with almost the same order of curvature everywhere, as +# otherwise the expansion require again a large number of basis to represent +# those details. + +############################################################################## +# Here we show the first five elements of a Fourier basis. + +basis = skfda.representation.basis.Fourier(n_basis=5) +basis.plot() + +############################################################################## +# We now show how the previous observations are represented using the first +# five elements of this basis. + +X_basis = X.to_basis(basis) +X_basis.plot() + +############################################################################## +# B-spline basis +# ^^^^^^^^^^^^^^ +# +# Splines are a family of functions that has taken importance with the advent +# of the modern computers, and nowadays are well known for a lot of engineers +# and designers. Esentially, they are piecewise polynomials that join smoothly +# at the separation points (usually called knots). Thus, both polynomials +# and piecewise linear functions are included in this family. Given a set of +# knots, a B-spline basis (class :class:`~skfda.representation.basis.BSpline`) +# of a given order can be used to express every spline of the same order that +# uses the same knots. +# +# This basis is a very powerful basis, as the knots can be adjusted to be able +# to express local features, and it is even possible to create points where +# the functions are not necessarily smooth or continuous by placing several +# knots together. Also the elements of the basis have the compact support +# property, which allows more efficient computations. Thus, this basis is +# indicated for non-periodic functions or functions with local features or with +# different orders of curvature along their domain. + +############################################################################## +# Here we show the first five elements of a B-spline basis. + +basis = skfda.representation.basis.BSpline(n_basis=5) +basis.plot() + +############################################################################## +# We now show how the previous observations are represented using the first +# five elements of this basis. + +X_basis = X.to_basis(basis) +X_basis.plot() + +############################################################################## +# Constant basis +# ^^^^^^^^^^^^^^ +# +# Sometimes it is useful to consider the basis whose only function is the +# constant one. In particular, using this basis we can view scalar values +# as functional observations, which can be used to combine multivariate +# and functional data in the same model. + +############################################################################## +# Tensor product basis +# ^^^^^^^^^^^^^^^^^^^^ +# +# The previously explained bases are useful for data that comes in the form +# of curves, that is, functions :math:`\{f_i: \mathbb{R} \to +# \mathbb{R}\}_{i=1}^N`. However, scikit-fda allows also the representation +# of surfaces or functions in higher dimensions. In this case it is even more +# useful to be able to represent them using basis expansions, as the number +# of parameters in the discretized representation grows as the product of the +# grid points in each dimension of the domain. +# +# The tensor product basis (class :class:`~skfda.representation.basis.Tensor`) +# allows the construction of basis for these higher dimensional functions as +# tensor products of :math:`\mathbb{R} \to \mathbb{R}` basis. + +############################################################################## +# As an example, we can import the digits datasets of scikit-learn, which are +# surfaces, and convert it to a basis expansion. Note that we use different +# basis for the different continuous parameters of the function in order to +# show how it works, although it probably makes no sense in this particular +# example. + +from sklearn.datasets import load_digits + +X, y = load_digits(return_X_y=True) +X = X.reshape(-1, 8, 8) + +fd = skfda.FDataGrid(X) + +basis = skfda.representation.basis.Tensor([ + skfda.representation.basis.Fourier( # X axis + n_basis=5, + domain_range=fd.domain_range[0], + ), + skfda.representation.basis.BSpline( # Y axis + n_basis=6, + domain_range=fd.domain_range[1], + ), +]) + +fd_basis = fd.to_basis(basis) + +# We only plot the first function +fd_basis[0].plot() + +############################################################################## +# Finite element basis +# ^^^^^^^^^^^^^^^^^^^^ +# +# A finite element basis (class +# :class:`~skfda.representation.basis.FiniteElement`) is a basis used in the +# finite element method (FEM). In order to instantiate a basis, it is +# necessary to pass a set of vertices and a set of simplices, or cells, that +# join them, conforming a grid. The basis elements are then functions that +# are one at exactly one of these vertices and zero in the rest of them. +# +# The advantage of this basis for higher dimensional functions is that one can +# have more control of the basis, placing more vertices in regions with +# interesting behaviour, such as local features and less elsewhere. + +############################################################################## +# Here we show an example where the digits dataset of scikit-learn is +# expressed in the finite element basis. First we create the vertices and +# simplices that we will use and we plot them. + +vertices = np.array([ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + (0.25, 0.5), + (0.5, 0.25), + (0.5, 0.75), + (0.75, 0.5), + (0.5, 0.5), +]) + +cells = np.array([ + (0, 1, 4), + (0, 2, 5), + (1, 3, 6), + (2, 3, 7), + (0, 4, 5), + (1, 4, 6), + (2, 5, 7), + (3, 6, 7), + (4, 5, 8), + (4, 6, 8), + (5, 7, 8), + (6, 7, 8), +]) + +plt.triplot(vertices[:, 0], vertices[:, 1], cells) + +############################################################################## +# We now represent the digits dataset in this basis. + +basis = skfda.representation.basis.FiniteElement( + vertices=vertices, + cells=cells, +) + +fd_basis = fd.to_basis(basis) + +# We only plot the first function +fd_basis[0].plot() + +############################################################################## +# Vector-valued basis +# ^^^^^^^^^^^^^^^^^^^ +# +# With the aforementioned bases, one could express +# :math:`\mathbb{R}^p \to \mathbb{R}` functions. In order to express vector +# valued functions as a basis expansion, one just need to express each +# coordinate function as a basis expansion and multiply it by the +# corresponding unitary vector in the coordinate direction, adding finally all +# of them together. +# +# The vector-valued basis (:class:`~skfda.representation.basis.VectorValued`) +# allows the representation of vector-valued functions doing just that. + +############################################################################## +# As an example, consider the Canadian Weather dataset, including both +# temperature and precipitation data as coordinate functions, and plotted +# below. + +X, y = skfda.datasets.fetch_weather(return_X_y=True) + +X.plot() + +############################################################################## +# We will express this dataset as a basis expansion. Temperatures +# are now expressed in a Fourier basis, while we express precipitations as +# B-splines. + +basis = skfda.representation.basis.VectorValued([ + skfda.representation.basis.Fourier( # First coordinate function + n_basis=5, + domain_range=X.domain_range, + ), + skfda.representation.basis.BSpline( # Second coordinate function + n_basis=10, + domain_range=X.domain_range, + ), +]) + +X_basis = X.to_basis(basis) +X_basis.plot() diff --git a/tutorial/plot_getting_data.py b/tutorial/plot_getting_data.py new file mode 100644 index 000000000..ef0e48ff5 --- /dev/null +++ b/tutorial/plot_getting_data.py @@ -0,0 +1,296 @@ +""" +Getting the data +================ + +In this section, we will dicuss how to get functional data to +use in scikit-fda. We will briefly describe the +:class:`~skfda.representation.grid.FDataGrid` class, which is the type that +scikit-fda uses for storing and working with functional data in discretized +form. We will discuss also how to import functional data from several sources +and show how to fetch and load existing datasets popular in the :term:`FDA` +literature. + +.. Disable isort + isort:skip_file + +""" + +# Author: Carlos Ramos Carreño +# License: MIT +# +# sphinx_gallery_thumbnail_number = 6 + +############################################################################## +# The FDataGrid class +# ------------------- +# +# In order to use scikit-fda, first we need functional data to analyze. +# A common case is to have each functional observation measured at the same +# points. +# This kind of functional data is easily representable in scikit-fda using +# the :class:`~skfda.representation.grid.FDataGrid` class. +# +# The :class:`~skfda.representation.grid.FDataGrid` has two important +# attributes: ``data_matrix`` and ``grid_points``. The attribute +# ``grid_points`` is a tuple with the same length as the number of domain +# dimensions (that is, one for curves, two for surfaces...). Each of its +# elements is a 1D numpy :class:`~numpy.ndarray` containing the measurement +# points for that particular dimension. The attribute ``data_matrix`` is a +# numpy :class:`~numpy.ndarray` containing the measured values of the +# functions in the grid spanned by the grid points. For functions +# :math:`\{f_i: \mathbb{R}^p \to \mathbb{R}^q\}_{i=1}^N` this is a tensor +# with dimensions :math:`N \times M_1 \times \ldots \times M_p \times q`, +# where :math:`M_i` is the number of measurement points for the domain +# dimension :math:`i`. + +############################################################################## +# In order to create a :class:`~skfda.representation.grid.FDataGrid`, these +# attributes may be provided. The attributes are converted to +# :class:`~numpy.ndarray` when necessary. + +############################################################################## +# .. note:: +# +# The grid points can be omitted, +# and in that case their number is inferred from the dimensions of +# ``data_matrix`` and they are automatically assigned as equispaced points +# in the unitary cube in the domain set. +# +# In the common case of functions with domain dimension of 1, the list of +# grid points can be passed directly as ``grid_points``. +# +# If the codomain dimension is 1, the last dimension of ``data_matrix`` +# can be dropped. + +############################################################################## +# The following example shows the creation of a +# :class:`~skfda.representation.grid.FDataGrid` with two functions (curves) +# :math:`\{f_i: \mathbb{R} \to \mathbb{R}\}, i=1,2` measured at the same +# (non-equispaced) points. + +import skfda + +grid_points = [0, 0.2, 0.5, 0.9, 1] # Grid points of the curves +data_matrix = [ + [0, 0.2, 0.5, 0.9, 1], # First observation + [0, 0.04, 0.25, 0.81, 1], # Second observation +] + +fd = skfda.FDataGrid( + data_matrix=data_matrix, + grid_points=grid_points, +) + +fd.plot() + +############################################################################## +# Advanced example +# ^^^^^^^^^^^^^^^^ +# +# In order to better understand the FDataGrid structure, you can consider the +# following example, in which a :class:`~skfda.representation.grid.FDataGrid` +# object is created, containing just one function (vector-valued surface) +# :math:`f: \mathbb{R}^2 \to \mathbb{R}^4`. + + +grid_points_surface = [ + [0.2, 0.5, 0.7], # Measurement points in first domain dimension + [0, 1.5], # Measurement points in second domain dimension +] + +data_matrix_surface = [ + # First observation + [ + # 0.2 + [ + # Value at (0.2, 0) + [1, 2, 3, 4], + # Value at (0.2, 1.5) + [0, 1, -1.3, 2], + ], + # 0.5 + [ + # Value at (0.5, 0) + [-2, 0, 5.5, 7], + # Value at (0.5, 1.5) + [2, 1.1, -1, -2], + ], + # 0.7 + [ + # Value at (0.7, 0) + [0, 0, 1, 1], + # Value at (0.7, 1.5) + [-3, 5, -0.5, -2], + ], + ], + # This example has only one observation. Next observations would be + # added here. +] + +fd = skfda.FDataGrid( + data_matrix=data_matrix_surface, + grid_points=grid_points_surface, +) + +fd.plot() + +############################################################################## +# Importing data +# -------------- +# +# Usually one does not construct manually the functions, but instead uses +# measurements already formatted in a common format, such as comma-separated +# values (CSV), attribute-relation file format (ARFF) or Matlab and R formats. +# +# If your data is in one of these formats, you can import it into a numpy +# array using the IO functions available in +# `Numpy `_ (for simple +# text-based or binary formats, such as CSV) or in +# `Scipy `_ (for Matlab, +# Fortran or ARFF files). For importing data in the R format one can also +# use the package `RData `_ with is already a +# dependency of scikit-fda, as it is used to load the example datasets. + +############################################################################## +# Once your data has been introduced as a :class:`~numpy.ndarray` instance, +# you will need to give it the proper dimensions and use it to instantiate +# a functional data object. + +############################################################################## +# .. note:: +# +# :class:`Pandas DataFrames ` are also popular as +# datasets containers in the Python scientific ecosystem. If you have +# data in a Pandas DataFrame, you can extract its content as a Numpy +# array using the method :meth:`~pandas.DataFrame.to_numpy` of the +# DataFrame. + +############################################################################## +# As an example, we will load the +# :func:`digits dataset ` of scikit-learn, which +# is a preprocessed subset of the MNIST dataset, containing digit images. The +# data is already a numpy array. As the data has been flattened into a 1D +# vector of pixels, we need to reshape the arrays to their original 8x8 shape. +# Then this array can be used to construct the digits as surfaces. + +from sklearn.datasets import load_digits + +X, y = load_digits(return_X_y=True) +X = X.reshape(-1, 8, 8) + +fd = skfda.FDataGrid(X) + +# Plot the first 2 observations +fd[0].plot() +fd[1].plot() + + +############################################################################## +# Common datasets +# --------------- +# +# scikit-fda can download and import for you several of the most popular +# datasets in the :term:`FDA` literature, such as the Berkeley Growth +# dataset (function :func:`~skfda.datasets.fetch_growth`) or the Canadian +# Weather dataset (function :func:`~skfda.datasets.fetch_weather`). These +# datasets are often useful as benchmarks, in order to compare results +# between different algorithms, or simply as examples to use in teaching or +# research. + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X.plot(group=y) + +############################################################################## +# Datasets from CRAN +# ^^^^^^^^^^^^^^^^^^ +# +# If you want to work with a dataset for which no fetching function exist, and +# you know that is available inside a R package in the CRAN repository, you +# can try using the function :func:`~skfda.datasets.fetch_cran`. This function +# will load the package, fetch the dataset and convert it to Python objects +# using the packages +# `scikit-datasets `_ and +# `RData `_. As datasets in CRAN follow no +# particular structure, you will need to know how it is structured internally +# in order to use it properly. + +############################################################################## +# .. note:: +# +# Functional data objects from some packages, such as +# `fda.usc `_ +# are automatically recognized as such and converted to +# :class:`~skfda.representation.grid.FDataGrid` instances. This +# behaviour can be disabled or customized to work with more packages. + +data = skfda.datasets.fetch_cran("MCO", "fda.usc") + +data["MCO"]["intact"].plot() + +############################################################################## +# Datasets from the UEA & UCR Time Series Classification Repository +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# The `UEA & UCR Time Series Classification Repository +# `_ is a popular repository +# for classification problems involving time series data. The datasets used +# can be considered also as functional observations, where the functions +# involved have domain dimension of 1, and the grid points are +# equispaced. Thus, they have also been used in the :term:`FDA` literature. +# The original UCR datasets are univariate time series, while the new UEA +# datasets incorporate also vector-valued data. +# In scikit-fda, the function :func:`~skfda.datasets.fetch_ucr` can be used +# to obtain both kinds of datasets as +# :class:`~skfda.representation.grid.FDataGrid` instances. + +# Load ArrowHead dataset from UCR +dataset = skfda.datasets.fetch_ucr("ArrowHead") +dataset["data"].plot() + +############################################################################## + +# Load BasicMotions dataset from UEA +dataset = skfda.datasets.fetch_ucr("BasicMotions") +dataset["data"].plot() + +############################################################################## +# Synthetic data +# -------------- +# +# Sometimes it is not enough to have real-world data at your disposal. +# Perhaps the messy nature of real-world data makes difficult to detect when +# a particular algorithm has a strange behaviour. Perhaps you want to see how +# it performs under a simplified model. Maybe you want to see what happens +# when your data has particular characteristics, for which no dataset is +# available. Or maybe you only want to illustrate a concept without having +# to introduce a particular set of data. +# +# In those cases, the ability to use generated data is desirable. To aid this +# use case, scikit-learn provides several functions that generate data +# according to some model. These functions are in the +# :doc:`datasets ` module and have the prefix ``make_``. +# Maybe the most useful of those are the functions +# :func:`skfda.datasets.make_gaussian_process` and +# :func:`skfda.datasets.make_gaussian` which can be used to generate Gaussian +# processes and Gaussian fields with different covariance functions. + +import numpy as np + +cov = skfda.misc.covariances.Exponential(length_scale=0.1) + +fd = skfda.datasets.make_gaussian_process( + start=0, + stop=4, + n_samples=5, + n_features=100, + mean=lambda t: np.power(t, 2), + cov=cov, +) + +fd.plot() + +############################################################################## +# In order to know all the available functionalities to load existing and +# synthetic datasets it is recommended to look at the documentation of the +# :doc:`datasets ` module. diff --git a/tutorial/plot_introduction.py b/tutorial/plot_introduction.py new file mode 100644 index 000000000..bda6df5b7 --- /dev/null +++ b/tutorial/plot_introduction.py @@ -0,0 +1,137 @@ +""" +Introduction +============ + +In this section, we will briefly explain what is +:term:`functional data analysis (FDA) `, and we will introduce +scikit-fda, a library that provides FDA tools to staticians, engineers or +machine learning practicioners in the Python scientific ecosystem. + +.. Disable isort + isort:skip_file + +""" + +# Author: Carlos Ramos Carreño +# License: MIT + +############################################################################## +# What is functional data analysis? +# --------------------------------- +# +# Traditional multivariate statistics focus on the simultaneous analysis of +# a finite number of variables. In this setting, we have several observations, +# each of them consisting on a vector of measured values. The variables, or +# coordinates of this vector, could be correlated, but otherwise they can be +# arbitrarily ordered inside the observed vector, provided that the order is +# the same for each observation. Usually these observations are considered +# to be instances of a random vector, and a big part of the analysis is +# centered in finding the distribution associated with it. +# +# In contrast, in functional data analysis each observation is a function of +# one or several variables, such as curves or surfaces. These functions are +# usually continuous and often they are smooth, and derivatives can be +# computed. The number of variables of these objects is then infinite, as each +# evaluation of the function at one point could be considered as one variable. +# Moreover, now it is not possible to reorder the variables of the +# observations without altering substantially its structure. If the functions +# are continuous, nearby variables are highly correlated, a characteristic +# that makes some classical multivariate methods unsuitable to work with this +# data. +# +# In this setting, observations can also be considered to be instances +# of a "functional random variable", usually called a stochastic process or +# a random field. However, some of the concepts that proved very useful to +# analyze multivariate data, such as density functions, are not applicable +# to :term:`functional data`, while new tools, such as taking derivatives, +# become available. +# +# As such, functional data can benefit of a separate analysis from +# multivariate statistics, but also adapting and extending multivariate +# techniques when possible. + +############################################################################## +# What is scikit-fda? +# ------------------- +# +# scikit-fda is a Python library containing classes and functions that allow +# you to perform functional data analysis tasks. Using it you can: +# +# - Represent functions as Python objects, both in a discretized fashion +# and as a basis expansion. +# - Apply preprocessing methods to functional data, including smoothing, +# registration and dimensionality reduction. +# - Perform a complete exploratory analysis of the data, summarizing its +# main properties, detecting possible outliers and visualizing the data +# in several ways. +# - Apply statistical inference tools developed for functional data, such +# as functional ANOVA. +# - Perform usual machine learning tasks, such as classification, +# regression or clustering, using functional observations. +# - Combine the tools offered by scikit-fda with other tools of the Python +# scientific ecosystem, such as those provided by the popular machine +# learning library `scikit-learn `_. + + +############################################################################## +# Anatomy of a function +# --------------------- +# +# We would like to briefly remind the reader the basic concepts that are +# employed to talk about functions. Functions in math are a relation between +# two sets, the :term:`domain` and the :term:`codomain` in which each element +# of the :term:`domain` is restricted to be related to exactly one element of +# the :term:`codomain`. The intuition behind this is that a function +# represents some type of deterministic process, that takes elements of the +# :term:`domain` as inputs and produces elements of the :term:`codomain` as +# outputs. +# +# In :term:`FDA`, the inputs or parameters of a function are assumed to be +# continuous parameters, and so are the outputs, or values of the function. +# Thus, it is usual to restrict our functional observations to be functions +# :math:`\{f_i: \mathcal{T} \subseteq \mathbb{R}^p \to \mathbb{R}^q\}_{i=1}^N`. +# In this case both the domain and codomain are (subsets of) vector spaces of +# real numbers, and one could talk of the dimension of each of them as a +# vector space (in this case the domain dimension is :math:`p` and the +# codomain dimension is :math:`q`). +# +# The most common case of functional observation, and the one that has +# received more attention in the functional data literature, is the case of +# functions +# :math:`\{f_i: \mathcal{T} \subseteq \mathbb{R} \to \mathbb{R}\}_{i=1}^N` +# (curves or trajectories). + +############################################################################## +# As an example, the following code shows the Berkeley Growth dataset, one +# of the classical datasets used in :term:`FDA`. The curves are heights of +# 93 boys and girls measured at several points since their birth to +# their 18th birthday. Here the domain :math:`\mathcal{T}` is the interval +# :math:`[0, 18]` and both the domain and codomain have a dimension of one. + +import skfda + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X.plot() + +############################################################################## +# Functions where the domain dimension is greater than one ( +# such as surfaces or higher dimensional objects) are referred to as functions +# of several variables. Functions where the codomain dimension is greater than +# one are called vector-valued functions. + +############################################################################## +# As an example we show another popular dataset: Canadian Weather. Here each +# observation correspond to data taken from a different weather station in +# Canada. For each day of the year we have two values: the average temperature +# at that day among several years and the average precipitation among the same +# years. Thus, here the domain :math:`\mathcal{T}` is the interval +# :math:`[0, 365)`, the domain dimension is one and the codomain dimension +# is two. We can see that by default each coordinate of the values of the +# function is plotted as a separate coordinate function. + +import skfda + +X, y = skfda.datasets.fetch_weather(return_X_y=True) + +X.plot() diff --git a/tutorial/plot_skfda_sklearn.py b/tutorial/plot_skfda_sklearn.py new file mode 100644 index 000000000..1b51bf765 --- /dev/null +++ b/tutorial/plot_skfda_sklearn.py @@ -0,0 +1,351 @@ +""" +Scikit-fda and scikit-learn +=========================== + +In this section, we will explain how scikit-fda interacts with the popular +machine learning package scikit-learn. We will introduce briefly the main +concepts of scikit-learn and how scikit-fda reuses the same concepts extending +them to the :term:`functional data analysis` field. + +.. Disable isort + isort:skip_file + +""" + +# Author: Carlos Ramos Carreño +# License: MIT + +############################################################################## +# A brief summary of scikit-learn architecture +# -------------------------------------------- +# +# The library `scikit-learn `_ is probably the most +# well-known Python package for machine learning. This package focuses in +# machine learning using multivariate data, which should be stored in a numpy +# :class:`~numpy.ndarray` in order to process it. However, this library has +# defined a particular architecture that can be followed in order to provide +# new tools that work in situations not even imagined by the original authors, +# while remaining compatible with the tools already provided in scikit-learn. +# +# In scikit-fda, the same architecture is applied in order to work with +# functional data observations. As a result, scikit-fda tools are +# largely compatible with scikit-learn tools, and it is possible to reuse +# objects such as :class:`pipelines ` or even +# hyperparameter selection methods such as +# :class:`grid search cross-validation ` +# in the functional data setting. +# +# We will introduce briefly the main concepts in scikit-learn, and explain how +# the tools in scikit-fda are related with them. This is not intended as a full +# explanation of scikit-learn architecture, and the reader is encouraged to +# look at the `scikit-learn tutorials +# `_ in order to achieve +# a deeper understanding of it. + +############################################################################## +# The Estimator object +# ^^^^^^^^^^^^^^^^^^^^ +# +# A central concept in scikit-learn (and scikit-fda) is what is called an +# estimator. An estimator in this context is an object that can learn from +# the data. Thus, classification, regression and clustering methods, as well +# as transformations with parameters learned from the training data are +# particular kinds of estimators. Estimators can also be instanced passing +# parameters, which can be tuned to the data using hyperparameter selection +# methods. +# +# Estimator objects have a ``fit`` method, with receive the training data +# and (if necessary) the training targets. This method uses the training data +# in order to learn some parameters of a model. When the learned parameters +# are part of the user-facing API, then by convention they are attributes of +# the estimator ending in with the ``_`` character. + +############################################################################## +# As a concrete example of this, consider a nearest centroid classifier +# for functional data. The object +# :class:`~skfda.ml.classification.NearestCentroid` is a classifier, and +# thus an estimator. As part of the training process the centroids of +# the classes are computed and available as the learned parameter +# ``centroids_``. +# +# .. note:: +# The function :func:`~sklearn.model_selection.train_test_split` is +# one of the functions originally from scikit-learn that can be +# directly reused in scikit-fda. + +import skfda +from sklearn.model_selection import train_test_split + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) + +classifier = skfda.ml.classification.NearestCentroid() +classifier.fit(X_train, y_train) +classifier.centroids_.plot() + +############################################################################## +# Transformers +# ^^^^^^^^^^^^ +# +# :term:`Transformers ` are estimators which can convert +# data to a new form. Examples of them are preprocessing methods, such as +# smoothing, registration and dimensionality reduction methods. They always +# implement ``fit_transform`` for fitting and transforming the data in one +# step. The transformers may be :term:`sklearn:inductive`, which means that +# can transform new data using the learned parameters. In that case they +# implement the ``transform`` method to transform new data. If the +# transformation is reversible, they usually also implement +# ``ìnverse_transform``. + +############################################################################## +# As an example consider the smoothing method +# :class:`skfda.preprocessing.smoothing.NadarayaWatson`. Smoothing methods +# attempt to remove noise from the data leveraging its continuous nature. +# As these methods discard information of the original data they usually are +# not reversible. + +import skfda.preprocessing.smoothing.kernel_smoothers as ks + +X, y = skfda.datasets.fetch_phoneme(return_X_y=True) + +# Keep the first 5 functions +X = X[:5] + +X.plot() + +smoother = ks.NadarayaWatsonSmoother() +X_smooth = smoother.fit_transform(X) + +X_smooth.plot() + +############################################################################## +# Predictors (classifiers, regressors, clusterers...) +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# :term:`Predictors ` in scikit-learn are estimators that +# can assign a certain target to a particular observation. This includes +# supervised methods such as classifiers (for which the target will be a class +# label), or regressors (for which the target is a real value, a vector, or, +# in functional data analysis, even a function!) and also unsupervised methods +# such as clusterers or outlying detector methods. +# +# Predictors should implement the ``fit_predict`` method for fitting the +# estimators and predicting the targets in one step and/or the ``predict`` +# method for predicting the targets of possibly non previously bserved data. +# Usually :term:`sklearn:transductive` estimators implement only the former +# one, while :term:`sklearn:inductive` estimators implement the latter one (or +# both). +# +# Predictors can have additional non-mandatory methods, such as +# ``predict-proba`` for obtaining the probability of a particular prediction +# or ``score`` for evaluating the results of the prediction. + +############################################################################## +# As an example, we can look at the :class:`~skfda.ml.clustering.KMeans` +# clustering method for functional data. This method will try to separate +# the data into different clusters according to the distance between +# observations. + +X, y = skfda.datasets.fetch_weather(return_X_y=True) + +# Use only the first value (temperature) +X = X.coordinates[0] + +clusterer = skfda.ml.clustering.KMeans(n_clusters=3) +y_pred = clusterer.fit_predict(X) + +X.plot(group=y_pred) + +############################################################################## +# Metaestimators +# ^^^^^^^^^^^^^^ +# +# In scikit-learn jargon, a :term:`sklearn:metaestimator` is an estimator +# that takes other estimators as parameters. There are several reasons for +# doing that, which will be explained now. + +############################################################################## +# Composition metaestimators +# ++++++++++++++++++++++++++ +# +# It is very common in machine learning to apply one or more preprocessing +# steps one after the other, before applying a final predictor. For this +# purpose scikit-learn offers the :class:`~sklearn.pipeline.Pipeline`, which +# join the steps together and uses the same estimator API for performing all +# steps in order (this is usually referred as the composite pattern in +# software engineering). The :class:`~sklearn.pipeline.Pipeline` estimator +# can be used with the functional data estimators available in scikit-fda. +# Moreover, as transformers such as dimensionality reduction methods can +# convert functional data to multivariate data usable by scikit-learn methods +# it is possible to mix methods from scikit-fda and scikit-learn in the same +# pipeline. +# +# .. warning:: +# In addition, scikit-learn offers estimators that can join several +# transformations as new features of the same dataset ( +# :class:`~sklearn.pipeline.FeatureUnion`) or that can apply different +# transformers to different columns of the data +# (:class:`~sklearn.compose.ColumnTransformer`). These transformers +# are not yet usable with functional data. + +############################################################################## +# As an example, we can construct a pipeline that registers the data using +# shift registation, then applies a variable selection method to +# transform each observation to a 3D vector and then uses a SVM classifier +# to classify the data. + +from skfda.preprocessing.dim_reduction import variable_selection as vs +from sklearn.pipeline import Pipeline +from sklearn.svm import SVC + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) + +pipeline = Pipeline([ + ("registration", skfda.preprocessing.registration.ShiftRegistration()), + ("dim_reduction", vs.RKHSVariableSelection(n_features_to_select=3)), + ("classifier", SVC()), +]) + +pipeline.fit(X_train, y_train) +pipeline.score(X_test, y_test) + +############################################################################## +# Hyperparameter optimizers +# +++++++++++++++++++++++++ +# +# Some of the parameters used for the creation of an estimator need to be +# tuned to each particular dataset in order to improve the prediction accuracy +# and generalization. There are several techniques to do that already +# available in scikit-learn, such as grid search cross-validation +# (:class:`~sklearn.model_selection.GridSearchCV`) or randomized search +# (:class:`~sklearn.model_selection.RandomizedSearchCV`). As these +# hyperparameter optimizers only need to split the data and call ``score`` in +# the predictor, they can be directly used with the methods in scikit-fda. +# +# .. note:: +# In addition one could use any optimizer that understand the scikit-learn +# API such as those in `scikit-optimize +# `_. + +############################################################################## +# As an example, we will use :class:`~sklearn.model_selection.GridSearchCV` +# to select the number of neighbors used in a +# :class:`~skfda.ml.classification.KNeighborsClassifier`. + +from sklearn.model_selection import GridSearchCV + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) + +classifier = skfda.ml.classification.KNeighborsClassifier() + +grid_search = GridSearchCV( + estimator=classifier, + param_grid={"n_neighbors": range(1, 10, 2)}, +) + +grid_search.fit(X_train, y_train) +n_neighbors = grid_search.best_estimator_.n_neighbors +score = grid_search.score(X_test, y_test) + +print(n_neighbors, score) + +############################################################################## +# Ensemble methods +# ++++++++++++++++ +# +# The ensemble methods :class:`~sklearn.ensemble.VotingClassifier` and +# :class:`~sklearn.ensemble.VotingRegressor` in scikit-learn use several +# different estimators in order to predict the targets. As this is done +# by evaluating the passed estimators as black boxes, these predictors can +# also be combined with scikit-fda predictors. +# +# .. warning:: +# Other ensemble methods, such as +# :class:`~sklearn.ensemble.BaggingClassifier` or +# :class:`~sklearn.ensemble.AdaBoostClassifier` cannot yet +# be used with functional data unless it has been +# transformed to a multivariate dataset. + +############################################################################## +# As an example we will use a voting classifier to classify data using as +# classifiers a knn-classifier, a nearest centroid classifier and a +# maximum depth classifier. + +from sklearn.ensemble import VotingClassifier + +X, y = skfda.datasets.fetch_growth(return_X_y=True) + +X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) + +knn = skfda.ml.classification.KNeighborsClassifier() +nearest_centroid = skfda.ml.classification.NearestCentroid() +mdc = skfda.ml.classification.MaximumDepthClassifier() + +voting = VotingClassifier([ + ("knn", knn), + ("nearest_centroid", nearest_centroid), + ("mdc", mdc), +]) + +voting.fit(X_train, y_train) +voting.score(X_test, y_test) + +############################################################################## +# Multiclass and multioutput classification utilities +# +++++++++++++++++++++++++++++++++++++++++++++++++++ +# +# The scikit-learn library also offers additional utilities that can convert +# a binary classifier into a multiclass classifier (such as +# :class:`~sklearn.multiclass.OneVsRestClassifier`) or to extend a single +# output classifier or regressor to accept also multioutput (vector-valued) +# targets. + +############################################################################## +# In this example we want to use as a classifier the combination of a +# dimensionality reduction method ( +# :class:`~skfda.preprocessing.dim_reduction.variable_selection.RKHSVariableSelection`) +# and a SVM classifier (:class:`~sklearn.svm.SVC`). As that particular +# dimensionality reduction method is only suitable for binary data, we use +# :class:`~sklearn.multiclass.OneVsRestClassifier` to classify in a +# multiclass dataset. + +from sklearn.multiclass import OneVsRestClassifier + +X, y = skfda.datasets.fetch_phoneme(return_X_y=True) + +X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) + +pipeline = Pipeline([ + ("dim_reduction", vs.RKHSVariableSelection(n_features_to_select=3)), + ("classifier", SVC()), +]) + +multiclass = OneVsRestClassifier(pipeline) + +multiclass.fit(X_train, y_train) +multiclass.score(X_test, y_test) + +############################################################################## +# Other scikit-learn utilities +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# In addition to the aforementioned objects, there are plenty of objects in +# scikit-learn that can be applied directly to functional data. We have +# already seen in the examples the function +# :func:`~sklearn.model_selection.train_test_split`. Other objects and +# functions such as :class:`~sklearn.model_selection.KFold` can be directly +# applied to functional data in order to split it into folds. Scorers for +# classification or regression, such as +# :func:`~sklearn.metrics.accuracy_score` can be directly applied to +# functional data problems. +# +# Moreover, there are plenty of libraries that aim to extend scikit-learn in +# several directions (take a look at the `list of related projects +# `_). You will +# probably see that a lot of the functionality can be applied to scikit-fda, +# as it uses the same API as scikit-learn.