From 9a6fe8aa34b888a63e79fec537614a7d74572678 Mon Sep 17 00:00:00 2001 From: William de Vazelhes <31916524+wdevazelhes@users.noreply.github.com> Date: Thu, 2 Jul 2020 14:54:13 +0200 Subject: [PATCH 01/40] Add the right version number everywhere (#300) --- doc/conf.py | 4 ++-- metric_learn/_version.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index eac09b38..812cc68a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -24,8 +24,8 @@ u'Bellet and Nathalie Vauquier') author = (u'CJ Carey, Yuan Tang, William de Vazelhes, Aurélien Bellet and ' u'Nathalie Vauquier') -version = '0.6.0' -release = '0.6.0' +version = '0.6.2' +release = '0.6.2' language = 'en' exclude_patterns = ['_build'] diff --git a/metric_learn/_version.py b/metric_learn/_version.py index 8411e551..aece342d 100644 --- a/metric_learn/_version.py +++ b/metric_learn/_version.py @@ -1 +1 @@ -__version__ = '0.6.1' +__version__ = '0.6.2' From 730abf1790ec15e117e2cf33132d612373b5d0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bellet?= Date: Thu, 30 Jul 2020 15:11:36 +0200 Subject: [PATCH 02/40] Update paper ref after JMLR acceptance (#301) * update paper ref * wrong space --- README.rst | 13 ++++++++----- doc/index.rst | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index ff770932..41181ee8 100644 --- a/README.rst +++ b/README.rst @@ -49,17 +49,20 @@ If you use metric-learn in a scientific publication, we would appreciate citations to the following paper: `metric-learn: Metric Learning Algorithms in Python -`_, de Vazelhes -*et al.*, arXiv:1908.04710, 2019. +`_, de Vazelhes +*et al.*, Journal of Machine Learning Research, 21(138):1-6, 2020. Bibtex entry:: - @techreport{metric-learn, + @article{metric-learn, title = {metric-learn: {M}etric {L}earning {A}lgorithms in {P}ython}, author = {{de Vazelhes}, William and {Carey}, CJ and {Tang}, Yuan and {Vauquier}, Nathalie and {Bellet}, Aur{\'e}lien}, - institution = {arXiv:1908.04710}, - year = {2019} + journal = {Journal of Machine Learning Research}, + year = {2020}, + volume = {21}, + number = {138}, + pages = {1--6} } .. _sphinx documentation: http://contrib.scikit-learn.org/metric-learn/ diff --git a/doc/index.rst b/doc/index.rst index 8f000246..6ec4fb26 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -15,17 +15,20 @@ If you use metric-learn in a scientific publication, we would appreciate citations to the following paper: `metric-learn: Metric Learning Algorithms in Python -`_, de Vazelhes -*et al.*, arXiv:1908.04710, 2019. +`_, de Vazelhes +*et al.*, Journal of Machine Learning Research, 21(138):1-6, 2020. Bibtex entry:: - @techreport{metric-learn, + @article{metric-learn, title = {metric-learn: {M}etric {L}earning {A}lgorithms in {P}ython}, author = {{de Vazelhes}, William and {Carey}, CJ and {Tang}, Yuan and {Vauquier}, Nathalie and {Bellet}, Aur{\'e}lien}, - institution = {arXiv:1908.04710}, - year = {2019} + journal = {Journal of Machine Learning Research}, + year = {2020}, + volume = {21}, + number = {138}, + pages = {1--6} } From 86a5208fc2c4f9f850ea1047d49ea6afec9b8292 Mon Sep 17 00:00:00 2001 From: William de Vazelhes Date: Thu, 30 Jul 2020 16:00:34 +0200 Subject: [PATCH 03/40] Fix matplotlib agg messages --- doc/conf.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 812cc68a..94263c7a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys import os +import warnings extensions = [ 'sphinx.ext.autodoc', @@ -80,3 +81,9 @@ def setup(app): app.add_javascript('js/copybutton.js') app.add_stylesheet("basic.css") + + +# Remove matplotlib agg warnings from generated doc when using plt.show +warnings.filterwarnings("ignore", category=UserWarning, + message='Matplotlib is currently using agg, which is a' + ' non-GUI backend, so cannot show the figure.') From 617adaa2cfd176e58efd6e5fe8147185c7d337cd Mon Sep 17 00:00:00 2001 From: William de Vazelhes <31916524+wdevazelhes@users.noreply.github.com> Date: Mon, 3 Aug 2020 15:08:33 +0200 Subject: [PATCH 04/40] [MRG] Fix test for components_from_metric and add tests for _check_sdp_from_eigen (#303) * Fix test for components_from_metric and add tests for _check_sdp_from_eigen * Fix trailing whitespace * Remove unused LinAlgError Co-authored-by: William de Vazelhes 80055062 --- test/test_components_metric_conversion.py | 10 ++--- test/test_utils.py | 47 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/test/test_components_metric_conversion.py b/test/test_components_metric_conversion.py index b9da87ed..04d0d007 100644 --- a/test/test_components_metric_conversion.py +++ b/test/test_components_metric_conversion.py @@ -1,7 +1,6 @@ import unittest import numpy as np import pytest -from numpy.linalg import LinAlgError from scipy.stats import ortho_group from sklearn.datasets import load_iris from numpy.testing import assert_array_almost_equal, assert_allclose @@ -117,17 +116,14 @@ def test_components_from_metric_edge_cases(self): L = components_from_metric(M) assert_allclose(L.T.dot(L), M) - # matrix with a determinant still high but which should be considered as a - # non-definite matrix (to check we don't test the definiteness with the - # determinant which is a bad strategy) + # matrix with a determinant still high but which is + # undefinite w.r.t to numpy standards M = np.diag([1e5, 1e5, 1e5, 1e5, 1e5, 1e5, 1e-20]) M = P.dot(M).dot(P.T) assert np.abs(np.linalg.det(M)) > 10 assert np.linalg.slogdet(M)[1] > 1 # (just to show that the computed # determinant is far from null) - with pytest.raises(LinAlgError) as err_msg: - np.linalg.cholesky(M) - assert str(err_msg.value) == 'Matrix is not positive definite' + assert np.linalg.matrix_rank(M) < M.shape[0] # (just to show that this case is indeed considered by numpy as an # indefinite case) L = components_from_metric(M) diff --git a/test/test_utils.py b/test/test_utils.py index fdcb864a..9b8b5e7e 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1055,6 +1055,53 @@ def test__check_sdp_from_eigen_returns_definiteness(w, is_definite): assert _check_sdp_from_eigen(w) == is_definite +@pytest.mark.unit +@pytest.mark.parametrize('w, tol, is_definite', + [(np.array([5., 3.]), 2, True), + (np.array([5., 1.]), 2, False), + (np.array([5., -1.]), 2, False)]) +def test__check_sdp_from_eigen_tol_psd(w, tol, is_definite): + """Tests that _check_sdp_from_eigen, for PSD matrices, returns + False if an eigenvalue is lower than tol""" + assert _check_sdp_from_eigen(w, tol=tol) == is_definite + + +@pytest.mark.unit +@pytest.mark.parametrize('w, tol', + [(np.array([5., -3.]), 2), + (np.array([1., -3.]), 2)]) +def test__check_sdp_from_eigen_tol_non_psd(w, tol): + """Tests that _check_sdp_from_eigen raises a NonPSDError + when there is a negative value with abs value higher than tol""" + with pytest.raises(NonPSDError): + _check_sdp_from_eigen(w, tol=tol) + + +@pytest.mark.unit +@pytest.mark.parametrize('w, is_definite', + [(np.array([1e5, 1e5, 1e5, 1e5, + 1e5, 1e5, 1e-20]), False), + (np.array([1e-10, 1e-10]), True)]) +def test__check_sdp_from_eigen_tol_default_psd(w, is_definite): + """Tests that the default tol argument gives good results for edge cases + like even if the determinant is high but clearly one eigenvalue is low, + (undefinite so returns False) or when all eigenvalues are low (definite so + returns True)""" + assert _check_sdp_from_eigen(w, tol=None) == is_definite + + +@pytest.mark.unit +@pytest.mark.parametrize('w', + [np.array([1., -1.]), + np.array([-1e-10, 1e-10])]) +def test__check_sdp_from_eigen_tol_default_non_psd(w): + """Tests that the default tol argument is good for raising + NonPSDError, e.g. that when a value is clearly relatively + negative it raises such an error""" + with pytest.raises(NonPSDError): + _check_sdp_from_eigen(w, tol=None) + + def test__check_n_components(): """Checks that n_components returns what is expected (including the errors)""" From eb878d6f42bb71f8f9ef7ac16eed108c9c3b6ecf Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Mon, 3 Aug 2020 17:03:45 -0400 Subject: [PATCH 05/40] Resolve a numpy deprecation warning --- test/test_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/test_utils.py b/test/test_utils.py index 9b8b5e7e..570b8578 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -931,16 +931,17 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): assert np.array(output_with_prep == output_with_prep_formed).all() # test score_pairs + idx1 = np.array([[0, 2],[5, 3]], dtype=int) output_with_prep = estimator_with_preprocessor.score_pairs( - indicators_to_transform[[[[0, 2], [5, 3]]]]) + indicators_to_transform[idx1]) output_without_prep = estimator_without_preprocessor.score_pairs( - formed_points_to_transform[[[[0, 2], [5, 3]]]]) + formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() output_with_prep = estimator_with_preprocessor.score_pairs( - indicators_to_transform[[[[0, 2], [5, 3]]]]) + indicators_to_transform[idx1]) output_without_prep = estimator_with_prep_formed.score_pairs( - formed_points_to_transform[[[[0, 2], [5, 3]]]]) + formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() # test transform From 66a12edbfba74774d84e6cd0f4f0fd5d438a79f8 Mon Sep 17 00:00:00 2001 From: Yuan Tang Date: Mon, 3 Aug 2020 21:35:39 -0400 Subject: [PATCH 06/40] Fix CI build - flake8 E231 (#304) --- test/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_utils.py b/test/test_utils.py index 570b8578..5ddf3d71 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -931,7 +931,7 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): assert np.array(output_with_prep == output_with_prep_formed).all() # test score_pairs - idx1 = np.array([[0, 2],[5, 3]], dtype=int) + idx1 = np.array([[0, 2], [5, 3]], dtype=int) output_with_prep = estimator_with_preprocessor.score_pairs( indicators_to_transform[idx1]) output_without_prep = estimator_without_preprocessor.score_pairs( From 7eef7c6f9f376ad6f482369519e04d9062adc31d Mon Sep 17 00:00:00 2001 From: William de Vazelhes <31916524+wdevazelhes@users.noreply.github.com> Date: Thu, 15 Apr 2021 18:15:51 +0200 Subject: [PATCH 07/40] [MRG+2] Update repo to work with both new and old scikit-learn (#313) * Update repo to work with both new and old scikit-learn, and add a travis job to test the old scikit-learn * Add random seed for test * fix str representation for various sklearn versions * fix flake8 error * add more samples to the test for robustness * add comment for additional travis test * change str to dict for simplicity * simplify conditional imports with sklearn_shims file * remove typo added blankline * empty commit Co-authored-by: William de Vazelhes 80055062 --- .travis.yml | 13 +++ metric_learn/sklearn_shims.py | 27 +++++ test/metric_learn_test.py | 2 +- test/test_base_metric.py | 122 +++++++++++++++++++--- test/test_components_metric_conversion.py | 2 +- test/test_mahalanobis_mixin.py | 2 +- test/test_pairs_classifiers.py | 2 +- test/test_quadruplets_classifiers.py | 2 +- test/test_sklearn_compat.py | 36 +++---- test/test_triplets_classifiers.py | 2 +- test/test_utils.py | 2 +- 11 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 metric_learn/sklearn_shims.py diff --git a/.travis.yml b/.travis.yml index d294c294..03ba1706 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,19 @@ matrix: - pytest test --cov; after_success: - bash <(curl -s https://codecov.io/bash) + - name: "Pytest python 3.6 with skggm + scikit-learn 0.20.3" + # checks that tests work for the oldest supported scikit-learn version + python: "3.6" + before_install: + - sudo apt-get install liblapack-dev + - pip install --upgrade pip pytest + - pip install wheel cython numpy scipy codecov pytest-cov + - pip install scikit-learn==0.20.3 + - pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION}; + script: + - pytest test --cov; + after_success: + - bash <(curl -s https://codecov.io/bash) - name: "Syntax checking with flake8" python: "3.7" before_install: diff --git a/metric_learn/sklearn_shims.py b/metric_learn/sklearn_shims.py new file mode 100644 index 00000000..654a9144 --- /dev/null +++ b/metric_learn/sklearn_shims.py @@ -0,0 +1,27 @@ +"""This file is for fixing imports due to different APIs +depending on the scikit-learn version""" +import sklearn +from packaging import version +SKLEARN_AT_LEAST_0_22 = (version.parse(sklearn.__version__) + >= version.parse('0.22.0')) +if SKLEARN_AT_LEAST_0_22: + from sklearn.utils._testing import (set_random_state, + assert_warns_message, + ignore_warnings, + assert_allclose_dense_sparse, + _get_args) + from sklearn.utils.estimator_checks import (_is_public_parameter + as is_public_parameter) + from sklearn.metrics._scorer import get_scorer +else: + from sklearn.utils.testing import (set_random_state, + assert_warns_message, + ignore_warnings, + assert_allclose_dense_sparse, + _get_args) + from sklearn.utils.estimator_checks import is_public_parameter + from sklearn.metrics.scorer import get_scorer + +__all__ = ['set_random_state', 'assert_warns_message', 'set_random_state', + 'ignore_warnings', 'assert_allclose_dense_sparse', '_get_args', + 'is_public_parameter', 'get_scorer'] diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 4db0a1fc..5cae80f2 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -9,7 +9,7 @@ make_spd_matrix) from numpy.testing import (assert_array_almost_equal, assert_array_equal, assert_allclose) -from sklearn.utils.testing import assert_warns_message +from metric_learn.sklearn_shims import assert_warns_message from sklearn.exceptions import ConvergenceWarning from sklearn.utils.validation import check_X_y from sklearn.preprocessing import StandardScaler diff --git a/test/test_base_metric.py b/test/test_base_metric.py index fed9018a..67f9b6a0 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -4,71 +4,161 @@ import metric_learn import numpy as np from sklearn import clone -from sklearn.utils.testing import set_random_state from test.test_utils import ids_metric_learners, metric_learners, remove_y +from metric_learn.sklearn_shims import set_random_state, SKLEARN_AT_LEAST_0_22 def remove_spaces(s): return re.sub(r'\s+', '', s) +def sk_repr_kwargs(def_kwargs, nndef_kwargs): + """Given the non-default arguments, and the default + keywords arguments, build the string that will appear + in the __repr__ of the estimator, depending on the + version of scikit-learn. + """ + if SKLEARN_AT_LEAST_0_22: + def_kwargs = {} + def_kwargs.update(nndef_kwargs) + args_str = ",".join(f"{key}={repr(value)}" + for key, value in def_kwargs.items()) + return args_str + + class TestStringRepr(unittest.TestCase): def test_covariance(self): + def_kwargs = {'preprocessor': None} + nndef_kwargs = {} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.Covariance())), - remove_spaces("Covariance()")) + remove_spaces(f"Covariance({merged_kwargs})")) def test_lmnn(self): + def_kwargs = {'convergence_tol': 0.001, 'init': 'auto', 'k': 3, + 'learn_rate': 1e-07, 'max_iter': 1000, 'min_iter': 50, + 'n_components': None, 'preprocessor': None, + 'random_state': None, 'regularization': 0.5, + 'verbose': False} + nndef_kwargs = {'convergence_tol': 0.01, 'k': 6} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.LMNN(convergence_tol=0.01, k=6))), - remove_spaces("LMNN(convergence_tol=0.01, k=6)")) + remove_spaces(f"LMNN({merged_kwargs})")) def test_nca(self): + def_kwargs = {'init': 'auto', 'max_iter': 100, 'n_components': None, + 'preprocessor': None, 'random_state': None, 'tol': None, + 'verbose': False} + nndef_kwargs = {'max_iter': 42} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.NCA(max_iter=42))), - remove_spaces("NCA(max_iter=42)")) + remove_spaces(f"NCA({merged_kwargs})")) def test_lfda(self): + def_kwargs = {'embedding_type': 'weighted', 'k': None, + 'n_components': None, 'preprocessor': None} + nndef_kwargs = {'k': 2} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.LFDA(k=2))), - remove_spaces("LFDA(k=2)")) + remove_spaces(f"LFDA({merged_kwargs})")) def test_itml(self): + def_kwargs = {'convergence_threshold': 0.001, 'gamma': 1.0, + 'max_iter': 1000, 'preprocessor': None, + 'prior': 'identity', 'random_state': None, 'verbose': False} + nndef_kwargs = {'gamma': 0.5} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.ITML(gamma=0.5))), - remove_spaces("ITML(gamma=0.5)")) + remove_spaces(f"ITML({merged_kwargs})")) + def_kwargs = {'convergence_threshold': 0.001, 'gamma': 1.0, + 'max_iter': 1000, 'num_constraints': None, + 'preprocessor': None, 'prior': 'identity', + 'random_state': None, 'verbose': False} + nndef_kwargs = {'num_constraints': 7} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.ITML_Supervised(num_constraints=7))), - remove_spaces("ITML_Supervised(num_constraints=7)")) + remove_spaces(f"ITML_Supervised({merged_kwargs})")) def test_lsml(self): + def_kwargs = {'max_iter': 1000, 'preprocessor': None, 'prior': 'identity', + 'random_state': None, 'tol': 0.001, 'verbose': False} + nndef_kwargs = {'tol': 0.1} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.LSML(tol=0.1))), - remove_spaces("LSML(tol=0.1)")) + remove_spaces(f"LSML({merged_kwargs})")) + def_kwargs = {'max_iter': 1000, 'num_constraints': None, + 'preprocessor': None, 'prior': 'identity', + 'random_state': None, 'tol': 0.001, 'verbose': False, + 'weights': None} + nndef_kwargs = {'verbose': True} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.LSML_Supervised(verbose=True))), - remove_spaces("LSML_Supervised(verbose=True)")) + remove_spaces(f"LSML_Supervised({merged_kwargs})")) def test_sdml(self): + def_kwargs = {'balance_param': 0.5, 'preprocessor': None, + 'prior': 'identity', 'random_state': None, + 'sparsity_param': 0.01, 'verbose': False} + nndef_kwargs = {'verbose': True} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.SDML(verbose=True))), - remove_spaces("SDML(verbose=True)")) + remove_spaces(f"SDML({merged_kwargs})")) + def_kwargs = {'balance_param': 0.5, 'num_constraints': None, + 'preprocessor': None, 'prior': 'identity', + 'random_state': None, 'sparsity_param': 0.01, + 'verbose': False} + nndef_kwargs = {'sparsity_param': 0.5} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.SDML_Supervised(sparsity_param=0.5))), - remove_spaces("SDML_Supervised(sparsity_param=0.5)")) + remove_spaces(f"SDML_Supervised({merged_kwargs})")) def test_rca(self): + def_kwargs = {'n_components': None, 'preprocessor': None} + nndef_kwargs = {'n_components': 3} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.RCA(n_components=3))), - remove_spaces("RCA(n_components=3)")) + remove_spaces(f"RCA({merged_kwargs})")) + def_kwargs = {'chunk_size': 2, 'n_components': None, 'num_chunks': 100, + 'preprocessor': None, 'random_state': None} + nndef_kwargs = {'num_chunks': 5} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.RCA_Supervised(num_chunks=5))), - remove_spaces("RCA_Supervised(num_chunks=5)")) + remove_spaces(f"RCA_Supervised({merged_kwargs})")) def test_mlkr(self): + def_kwargs = {'init': 'auto', 'max_iter': 1000, + 'n_components': None, 'preprocessor': None, + 'random_state': None, 'tol': None, 'verbose': False} + nndef_kwargs = {'max_iter': 777} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.MLKR(max_iter=777))), - remove_spaces("MLKR(max_iter=777)")) + remove_spaces(f"MLKR({merged_kwargs})")) def test_mmc(self): + def_kwargs = {'convergence_threshold': 0.001, 'diagonal': False, + 'diagonal_c': 1.0, 'init': 'identity', 'max_iter': 100, + 'max_proj': 10000, 'preprocessor': None, + 'random_state': None, 'verbose': False} + nndef_kwargs = {'diagonal': True} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.MMC(diagonal=True))), - remove_spaces("MMC(diagonal=True)")) + remove_spaces(f"MMC({merged_kwargs})")) + def_kwargs = {'convergence_threshold': 1e-06, 'diagonal': False, + 'diagonal_c': 1.0, 'init': 'identity', 'max_iter': 100, + 'max_proj': 10000, 'num_constraints': None, + 'preprocessor': None, 'random_state': None, + 'verbose': False} + nndef_kwargs = {'max_iter': 1} + merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( remove_spaces(str(metric_learn.MMC_Supervised(max_iter=1))), - remove_spaces("MMC_Supervised(max_iter=1)")) + remove_spaces(f"MMC_Supervised({merged_kwargs})")) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, diff --git a/test/test_components_metric_conversion.py b/test/test_components_metric_conversion.py index 04d0d007..5502ad90 100644 --- a/test/test_components_metric_conversion.py +++ b/test/test_components_metric_conversion.py @@ -4,7 +4,7 @@ from scipy.stats import ortho_group from sklearn.datasets import load_iris from numpy.testing import assert_array_almost_equal, assert_allclose -from sklearn.utils.testing import ignore_warnings +from metric_learn.sklearn_shims import ignore_warnings from metric_learn import ( LMNN, NCA, LFDA, Covariance, MLKR, diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index ab7e972d..84058b32 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -11,7 +11,7 @@ from sklearn.datasets import make_spd_matrix, make_blobs from sklearn.utils import check_random_state, shuffle from sklearn.utils.multiclass import type_of_target -from sklearn.utils.testing import set_random_state +from metric_learn.sklearn_shims import set_random_state from metric_learn._util import make_context, _initialize_metric_mahalanobis from metric_learn.base_metric import (_QuadrupletsClassifierMixin, diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index c5ca27f4..824bb622 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -11,7 +11,7 @@ from sklearn.model_selection import train_test_split from test.test_utils import pairs_learners, ids_pairs_learners -from sklearn.utils.testing import set_random_state +from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np from itertools import product diff --git a/test/test_quadruplets_classifiers.py b/test/test_quadruplets_classifiers.py index efe10030..a8319961 100644 --- a/test/test_quadruplets_classifiers.py +++ b/test/test_quadruplets_classifiers.py @@ -3,7 +3,7 @@ from sklearn.model_selection import train_test_split from test.test_utils import quadruplets_learners, ids_quadruplets_learners -from sklearn.utils.testing import set_random_state +from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index e18eb7f4..3ad69712 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -4,10 +4,9 @@ from sklearn.base import TransformerMixin from sklearn.pipeline import make_pipeline from sklearn.utils import check_random_state -from sklearn.utils.estimator_checks import is_public_parameter -from sklearn.utils.testing import (assert_allclose_dense_sparse, - set_random_state) - +from metric_learn.sklearn_shims import (assert_allclose_dense_sparse, + set_random_state, _get_args, + is_public_parameter, get_scorer) from metric_learn import (Covariance, LFDA, LMNN, MLKR, NCA, ITML_Supervised, LSML_Supervised, MMC_Supervised, RCA_Supervised, SDML_Supervised, @@ -16,8 +15,6 @@ import numpy as np from sklearn.model_selection import (cross_val_score, cross_val_predict, train_test_split, KFold) -from sklearn.metrics.scorer import get_scorer -from sklearn.utils.testing import _get_args from test.test_utils import (metric_learners, ids_metric_learners, mock_preprocessor, tuples_learners, ids_tuples_learners, pairs_learners, @@ -52,37 +49,37 @@ def __init__(self, sparsity_param=0.01, class TestSklearnCompat(unittest.TestCase): def test_covariance(self): - check_estimator(Covariance) + check_estimator(Covariance()) def test_lmnn(self): - check_estimator(LMNN) + check_estimator(LMNN()) def test_lfda(self): - check_estimator(LFDA) + check_estimator(LFDA()) def test_mlkr(self): - check_estimator(MLKR) + check_estimator(MLKR()) def test_nca(self): - check_estimator(NCA) + check_estimator(NCA()) def test_lsml(self): - check_estimator(LSML_Supervised) + check_estimator(LSML_Supervised()) def test_itml(self): - check_estimator(ITML_Supervised) + check_estimator(ITML_Supervised()) def test_mmc(self): - check_estimator(MMC_Supervised) + check_estimator(MMC_Supervised()) def test_sdml(self): - check_estimator(Stable_SDML_Supervised) + check_estimator(Stable_SDML_Supervised()) def test_rca(self): - check_estimator(Stable_RCA_Supervised) + check_estimator(Stable_RCA_Supervised()) def test_scml(self): - check_estimator(SCML_Supervised) + check_estimator(SCML_Supervised()) RNG = check_random_state(0) @@ -121,7 +118,8 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): # we subsample the data for the test to be more efficient input_data, _, labels, _ = train_test_split(input_data, labels, - train_size=20) + train_size=40, + random_state=42) X = X[:10] estimator = clone(estimator) @@ -160,7 +158,7 @@ def test_various_scoring_on_tuples_learners(estimator, build_dataset, with_preprocessor): """Tests that scikit-learn's scoring returns something finite, for other scoring than default scoring. (List of scikit-learn's scores can be - found in sklearn.metrics.scorer). For each type of output (predict, + found in sklearn.metrics._scorer). For each type of output (predict, predict_proba, decision_function), we test a bunch of scores. We only test on pairs learners because quadruplets don't have a y argument. """ diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index 10393919..0f0bf7df 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -3,7 +3,7 @@ from sklearn.model_selection import train_test_split from test.test_utils import triplets_learners, ids_triplets_learners -from sklearn.utils.testing import set_random_state +from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np diff --git a/test/test_utils.py b/test/test_utils.py index 5ddf3d71..072b94c5 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -5,7 +5,7 @@ from numpy.testing import assert_array_equal, assert_equal from sklearn.model_selection import train_test_split from sklearn.utils import check_random_state, shuffle -from sklearn.utils.testing import set_random_state +from metric_learn.sklearn_shims import set_random_state from sklearn.base import clone from metric_learn._util import (check_input, make_context, preprocess_tuples, make_name, preprocess_points, From d12bf3704ee0461d36af4422a733c2419cbf22f8 Mon Sep 17 00:00:00 2001 From: William de Vazelhes <31916524+wdevazelhes@users.noreply.github.com> Date: Thu, 22 Apr 2021 18:33:17 +0200 Subject: [PATCH 08/40] [MRG+1] add github issues buttons (#316) * add github issues buttons * add blankline at end of file --- .../bug_report.md} | 12 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 18 +++++++++++++++ .github/ISSUE_TEMPLATE/doc_improvement.md | 23 +++++++++++++++++++ .../ISSUE_TEMPLATE/enhancement_proposal.md | 18 +++++++++++++++ 4 files changed, 71 insertions(+) rename .github/{issue_template.md => ISSUE_TEMPLATE/bug_report.md} (82%) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/doc_improvement.md create mode 100644 .github/ISSUE_TEMPLATE/enhancement_proposal.md diff --git a/.github/issue_template.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 82% rename from .github/issue_template.md rename to .github/ISSUE_TEMPLATE/bug_report.md index d4fb0abe..ae757838 100644 --- a/.github/issue_template.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,9 @@ +--- +name: Reproducible bug report +about: Create a reproducible bug report. Not for support requests. +labels: 'bug' +--- + #### Description @@ -42,3 +48,9 @@ $ pip show metric_learn | grep Version ) --> + +--- + +**Message from the maintainers**: + +Impacted by this bug? Give it a 👍. We prioritise the issues with the most 👍. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c979fd98 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,18 @@ +blank_issues_enabled: false + +contact_links: + - name: Have you read the docs? + url: http://contrib.scikit-learn.org/metric-learn/ + about: Much help can be found in the docs + - name: Ask a question + url: https://github.com/sklearn-contrib/metric-learn/discussions/new + about: Ask a question or start a discussion about metric-learn + - name: Stack Overflow + url: https://stackoverflow.com + about: Please ask and answer metric-learn usage questions (API, installation...) on Stack Overflow + - name: Cross Validated + url: https://stats.stackexchange.com + about: Please ask and answer metric learning questions (use cases, algorithms & theory...) on Cross Validated + - name: Blank issue + url: https://github.com/sklearn-contrib/metric-learn/issues/new + about: Please note that Github Discussions should be used in most cases instead diff --git a/.github/ISSUE_TEMPLATE/doc_improvement.md b/.github/ISSUE_TEMPLATE/doc_improvement.md new file mode 100644 index 00000000..753cf2f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/doc_improvement.md @@ -0,0 +1,23 @@ +--- +name: Documentation improvement +about: Create a report to help us improve the documentation. Alternatively you can just open a pull request with the suggested change. +labels: Documentation +--- + +#### Describe the issue linked to the documentation + + + +#### Suggest a potential alternative/fix + + + +--- + +**Message from the maintainers**: + +Confused by this part of the doc too? Give it a 👍. We prioritise the issues with the most 👍. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/enhancement_proposal.md b/.github/ISSUE_TEMPLATE/enhancement_proposal.md new file mode 100644 index 00000000..01dfb1d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement_proposal.md @@ -0,0 +1,18 @@ +--- +name: Enhancement proposal +about: Propose an enhancement for metric-learn +labels: 'enhancement' +--- +# Summary + +What change needs making? + +# Use Cases + +When would you use this? + +--- + +**Message from the maintainers**: + +Want to see this feature happen? Give it a 👍. We prioritise the issues with the most 👍. \ No newline at end of file From 137880d9c6ce9a2b81a8af24c07d80e528f657cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bellet?= Date: Mon, 26 Apr 2021 10:27:27 +0200 Subject: [PATCH 09/40] fix url issue buttons (#319) --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c979fd98..415acfcd 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -5,7 +5,7 @@ contact_links: url: http://contrib.scikit-learn.org/metric-learn/ about: Much help can be found in the docs - name: Ask a question - url: https://github.com/sklearn-contrib/metric-learn/discussions/new + url: https://github.com/scikit-learn-contrib/metric-learn/discussions/new about: Ask a question or start a discussion about metric-learn - name: Stack Overflow url: https://stackoverflow.com @@ -14,5 +14,5 @@ contact_links: url: https://stats.stackexchange.com about: Please ask and answer metric learning questions (use cases, algorithms & theory...) on Cross Validated - name: Blank issue - url: https://github.com/sklearn-contrib/metric-learn/issues/new + url: https://github.com/scikit-learn-contrib/metric-learn/issues/new about: Please note that Github Discussions should be used in most cases instead From 8583df4b9e56dae540f9c0ace86bfd3070dce235 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Tue, 14 Sep 2021 15:12:35 +0200 Subject: [PATCH 10/40] Fix 5 test regarding make_spd_matrix usage from sklearn (#321) * Add venv to gitignore * Fix 5 test regarding make_spd_matrix usage from sklearn --- .gitignore | 3 ++- test/metric_learn_test.py | 4 ++-- test/test_mahalanobis_mixin.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 8321c7d2..16917890 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ htmlcov/ .cache/ .pytest_cache/ doc/auto_examples/* -doc/generated/* \ No newline at end of file +doc/generated/* +venv/ \ No newline at end of file diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 5cae80f2..4d058c8d 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -929,7 +929,7 @@ def test_singleton_class(self): X = X[[ind_0[0], ind_1[0], ind_2[0]]] y = y[[ind_0[0], ind_1[0], ind_2[0]]] - A = make_spd_matrix(X.shape[1], X.shape[1]) + A = make_spd_matrix(n_dim=X.shape[1], random_state=X.shape[1]) nca = NCA(init=A, max_iter=30, n_components=X.shape[1]) nca.fit(X, y) assert_array_equal(nca.components_, A) @@ -940,7 +940,7 @@ def test_one_class(self): X = self.iris_points[self.iris_labels == 0] y = self.iris_labels[self.iris_labels == 0] - A = make_spd_matrix(X.shape[1], X.shape[1]) + A = make_spd_matrix(n_dim=X.shape[1], random_state=X.shape[1]) nca = NCA(init=A, max_iter=30, n_components=X.shape[1]) nca.fit(X, y) assert_array_equal(nca.components_, A) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 84058b32..e3d981a4 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -503,12 +503,12 @@ def test_init_mahalanobis(estimator, build_dataset): model.fit(input_data, labels) # Initialize with a random spd matrix - init = make_spd_matrix(X.shape[1], random_state=rng) + init = make_spd_matrix(n_dim=X.shape[1], random_state=rng) model.set_params(**{param: init}) model.fit(input_data, labels) # init.shape[1] must match X.shape[1] - init = make_spd_matrix(X.shape[1] + 1, X.shape[1] + 1) + init = make_spd_matrix(n_dim=X.shape[1] + 1, random_state=rng) model.set_params(**{param: init}) msg = ('The input dimensionality {} of the given ' 'mahalanobis matrix `{}` must match the ' From 10b6d25a6e3e4c4eddef1945ace718dbd4b71183 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Wed, 15 Sep 2021 21:58:11 +0200 Subject: [PATCH 11/40] Addressing LFDA sign indeterminacy (#326) * Add venv to gitignore * Note proposed to address sign indeterminancy --- doc/supervised.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/supervised.rst b/doc/supervised.rst index 1b1180e9..c6d8b68b 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -292,6 +292,11 @@ same class are not imposed to be close. lfda = LFDA(k=2, dim=2) lfda.fit(X, Y) +.. note:: + LDFA suffers from a problem called “sign indeterminacy”, which means the sign of the ``components`` and the output from transform depend on a random state. This is directly related to the calculation of eigenvectors in the algorithm. The same input ran in different times might lead to different transforms, but both valid. + + To work around this, fit instances of this class to data once, then keep the instance around to do transformations. + .. topic:: References: .. [1] Sugiyama. `Dimensionality Reduction of Multimodal Labeled Data by Local From 5427159e5258e60df291ae01655f4f3d130d2c62 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Thu, 16 Sep 2021 13:14:08 +0200 Subject: [PATCH 12/40] YML file: CI Migration to Github Actions (#327) --- .github/workflows/main.yml | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..46e5d2c9 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,68 @@ +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + + # Checks compatibility with an old version of sklearn (0.20.3) + compatibility: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.6', '3.7', '3.8'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run Tests with skggm + scikit-learn 0.20.3 + env: + SKGGM_VERSION: a0ed406586c4364ea3297a658f415e13b5cbdaf8 + run: | + sudo apt-get install liblapack-dev + pip install --upgrade pip pytest + pip install wheel cython numpy scipy codecov pytest-cov + pip install scikit-learn==0.20.3 + pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION} + pytest test --cov + bash <(curl -s https://codecov.io/bash) + + # Run normal testing with the latests versions of all dependencies + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.6', '3.7', '3.8', '3.9'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run Tests without skggm + run: | + sudo apt-get install liblapack-dev + pip install --upgrade pip pytest + pip install wheel cython numpy scipy codecov pytest-cov scikit-learn + pytest test --cov + bash <(curl -s https://codecov.io/bash) + - name: Run Tests with skggm + env: + SKGGM_VERSION: a0ed406586c4364ea3297a658f415e13b5cbdaf8 + run: | + pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION} + pytest test --cov + bash <(curl -s https://codecov.io/bash) + - name: Syntax checking with flake8 + run: | + pip install flake8 + flake8 --extend-ignore=E111,E114 --show-source; From f548b1e781821dd7420c6e793ac9d28449f42770 Mon Sep 17 00:00:00 2001 From: Yuan Tang Date: Thu, 16 Sep 2021 09:50:24 -0400 Subject: [PATCH 13/40] Tidy up files to reflect the migration to GitHub Actions (#328) Signed-off-by: Yuan Tang --- .travis.yml | 65 --------------- README.rst | 6 +- build_tools/travis/flake8_diff.sh | 132 ------------------------------ 3 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 .travis.yml delete mode 100644 build_tools/travis/flake8_diff.sh diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 03ba1706..00000000 --- a/.travis.yml +++ /dev/null @@ -1,65 +0,0 @@ -language: python -sudo: false -cache: pip -language: python -env: - global: - - SKGGM_VERSION=a0ed406586c4364ea3297a658f415e13b5cbdaf8 -matrix: - include: - - name: "Pytest python 3.6 without skggm" - python: "3.6" - before_install: - - sudo apt-get install liblapack-dev - - pip install --upgrade pip pytest - - pip install wheel cython numpy scipy codecov pytest-cov scikit-learn - script: - - pytest test --cov; - after_success: - - bash <(curl -s https://codecov.io/bash) - - name: "Pytest python 3.6 with skggm" - python: "3.6" - before_install: - - sudo apt-get install liblapack-dev - - pip install --upgrade pip pytest - - pip install wheel cython numpy scipy codecov pytest-cov scikit-learn - - pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION}; - script: - - pytest test --cov; - after_success: - - bash <(curl -s https://codecov.io/bash) - - name: "Pytest python 3.7 with skggm" - python: "3.7" - before_install: - - sudo apt-get install liblapack-dev - - pip install --upgrade pip pytest - - pip install wheel cython numpy scipy codecov pytest-cov scikit-learn - - pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION}; - script: - - pytest test --cov; - after_success: - - bash <(curl -s https://codecov.io/bash) - - name: "Pytest python 3.6 with skggm + scikit-learn 0.20.3" - # checks that tests work for the oldest supported scikit-learn version - python: "3.6" - before_install: - - sudo apt-get install liblapack-dev - - pip install --upgrade pip pytest - - pip install wheel cython numpy scipy codecov pytest-cov - - pip install scikit-learn==0.20.3 - - pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION}; - script: - - pytest test --cov; - after_success: - - bash <(curl -s https://codecov.io/bash) - - name: "Syntax checking with flake8" - python: "3.7" - before_install: - - pip install flake8 - script: - - flake8 --extend-ignore=E111,E114 --show-source; - # Use this instead to have a syntax check only on the diff: - # - source ./build_tools/travis/flake8_diff.sh; -branches: - only: - - master diff --git a/README.rst b/README.rst index 41181ee8..681e29f6 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Travis-CI Build Status| |License| |PyPI version| |Code coverage| +|GitHub Actions Build Status| |License| |PyPI version| |Code coverage| metric-learn: Metric Learning in Python ======================================= @@ -67,8 +67,8 @@ Bibtex entry:: .. _sphinx documentation: http://contrib.scikit-learn.org/metric-learn/ -.. |Travis-CI Build Status| image:: https://api.travis-ci.org/scikit-learn-contrib/metric-learn.svg?branch=master - :target: https://travis-ci.org/scikit-learn-contrib/metric-learn +.. |GitHub Actions Build Status| image:: https://github.com/scikit-learn-contrib/metric-learn/workflows/CI/badge.svg + :target: https://github.com/scikit-learn-contrib/metric-learn/actions?query=event%3Apush+branch%3Amaster .. |License| image:: http://img.shields.io/:license-mit-blue.svg?style=flat :target: http://badges.mit-license.org .. |PyPI version| image:: https://badge.fury.io/py/metric-learn.svg diff --git a/build_tools/travis/flake8_diff.sh b/build_tools/travis/flake8_diff.sh deleted file mode 100644 index aea926c8..00000000 --- a/build_tools/travis/flake8_diff.sh +++ /dev/null @@ -1,132 +0,0 @@ -# This file is not used yet but we keep it in case we need to check the pep8 difference -# on the diff (see .travis.yml) -# -#!/bin/bash -# copied-pasted and adapted from http://github.com/sklearn-contrib/imbalanced-learn -# (more precisely: https://raw.githubusercontent.com/glemaitre/imbalanced-learn -# /adcb9d8e6210b321dac2c1b06879e5e889d52d77/build_tools/travis/flake8_diff.sh) - -# This script is used in Travis to check that PRs do not add obvious -# flake8 violations. It relies on two things: -# - find common ancestor between branch and -# scikit-learn/scikit-learn remote -# - run flake8 --diff on the diff between the branch and the common -# ancestor -# -# Additional features: -# - the line numbers in Travis match the local branch on the PR -# author machine. -# - ./build_tools/travis/flake8_diff.sh can be run locally for quick -# turn-around - -set -e -# pipefail is necessary to propagate exit codes -set -o pipefail - -PROJECT=scikit-learn-contrib/metric-learn -PROJECT_URL=https://github.com/$PROJECT.git - -# Find the remote with the project name (upstream in most cases) -REMOTE=$(git remote -v | grep $PROJECT | cut -f1 | head -1 || echo '') - -# Add a temporary remote if needed. For example this is necessary when -# Travis is configured to run in a fork. In this case 'origin' is the -# fork and not the reference repo we want to diff against. -if [[ -z "$REMOTE" ]]; then - TMP_REMOTE=tmp_reference_upstream - REMOTE=$TMP_REMOTE - git remote add $REMOTE $PROJECT_URL -fi - -echo "Remotes:" -echo '--------------------------------------------------------------------------------' -git remote --verbose - -# Travis does the git clone with a limited depth (50 at the time of -# writing). This may not be enough to find the common ancestor with -# $REMOTE/master so we unshallow the git checkout -if [[ -a .git/shallow ]]; then - echo -e '\nTrying to unshallow the repo:' - echo '--------------------------------------------------------------------------------' - git fetch --unshallow -fi - -if [[ "$TRAVIS" == "true" ]]; then - if [[ "$TRAVIS_PULL_REQUEST" == "false" ]] - then - # In main repo, using TRAVIS_COMMIT_RANGE to test the commits - # that were pushed into a branch - if [[ "$PROJECT" == "$TRAVIS_REPO_SLUG" ]]; then - if [[ -z "$TRAVIS_COMMIT_RANGE" ]]; then - echo "New branch, no commit range from Travis so passing this test by convention" - exit 0 - fi - COMMIT_RANGE=$TRAVIS_COMMIT_RANGE - fi - else - # We want to fetch the code as it is in the PR branch and not - # the result of the merge into master. This way line numbers - # reported by Travis will match with the local code. - LOCAL_BRANCH_REF=travis_pr_$TRAVIS_PULL_REQUEST - # In Travis the PR target is always origin - git fetch origin pull/$TRAVIS_PULL_REQUEST/head:refs/$LOCAL_BRANCH_REF - fi -fi - -# If not using the commit range from Travis we need to find the common -# ancestor between $LOCAL_BRANCH_REF and $REMOTE/master -if [[ -z "$COMMIT_RANGE" ]]; then - if [[ -z "$LOCAL_BRANCH_REF" ]]; then - LOCAL_BRANCH_REF=$(git rev-parse --abbrev-ref HEAD) - fi - echo -e "\nLast 2 commits in $LOCAL_BRANCH_REF:" - echo '--------------------------------------------------------------------------------' - git log -2 $LOCAL_BRANCH_REF - - REMOTE_MASTER_REF="$REMOTE/master" - # Make sure that $REMOTE_MASTER_REF is a valid reference - echo -e "\nFetching $REMOTE_MASTER_REF" - echo '--------------------------------------------------------------------------------' - git fetch $REMOTE master:refs/remotes/$REMOTE_MASTER_REF - LOCAL_BRANCH_SHORT_HASH=$(git rev-parse --short $LOCAL_BRANCH_REF) - REMOTE_MASTER_SHORT_HASH=$(git rev-parse --short $REMOTE_MASTER_REF) - - COMMIT=$(git merge-base $LOCAL_BRANCH_REF $REMOTE_MASTER_REF) || \ - echo "No common ancestor found for $(git show $LOCAL_BRANCH_REF -q) and $(git show $REMOTE_MASTER_REF -q)" - - if [ -z "$COMMIT" ]; then - exit 1 - fi - - COMMIT_SHORT_HASH=$(git rev-parse --short $COMMIT) - - echo -e "\nCommon ancestor between $LOCAL_BRANCH_REF ($LOCAL_BRANCH_SHORT_HASH)"\ - "and $REMOTE_MASTER_REF ($REMOTE_MASTER_SHORT_HASH) is $COMMIT_SHORT_HASH:" - echo '--------------------------------------------------------------------------------' - git show --no-patch $COMMIT_SHORT_HASH - - COMMIT_RANGE="$COMMIT_SHORT_HASH..$LOCAL_BRANCH_SHORT_HASH" - - if [[ -n "$TMP_REMOTE" ]]; then - git remote remove $TMP_REMOTE - fi - -else - echo "Got the commit range from Travis: $COMMIT_RANGE" -fi - -echo -e '\nRunning flake8 on the diff in the range' "$COMMIT_RANGE" \ - "($(git rev-list $COMMIT_RANGE | wc -l) commit(s)):" -echo '--------------------------------------------------------------------------------' - -# to not include the context (some lines before and after the modified lines), add the -# flag --unified=0 (warning: it will not include some errors like for instance adding too -# much blank lines -check_files() { - git diff $COMMIT_RANGE | flake8 --diff --show-source --extend-ignore=E111,E114 -} - -check_files - -echo -e "No problem detected by flake8\n" - From 8571f975c4e114b1823b2007ea2eacdfa59b81ea Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Fri, 1 Oct 2021 17:20:49 +0200 Subject: [PATCH 14/40] SCML iris test refactor [Minor] (#332) * Add venv to gitignore * Create yml draft Trying to run tests with github actions * Update yml * Update 2 * Update 3 * YML: Only Python3 versions * Add Codecov to CI * Mirroring actual yml from metric_learning repo * Fix codecov * Fix old scikit learn * Update yml * Remove 3.9 from compatibility * Fixed issue with sklearn 0.20 * Delete comments, and unnecesary workflow_dispatch * SCML test refactor * Commented te test --- test/metric_learn_test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 4d058c8d..2debe426 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -79,12 +79,17 @@ def test_singular_returns_pseudo_inverse(self): class TestSCML(object): @pytest.mark.parametrize('basis', ('lda', 'triplet_diffs')) def test_iris(self, basis): + """ + SCML applied to Iris dataset should give better results when + computing class separation. + """ X, y = load_iris(return_X_y=True) + before = class_separation(X, y) scml = SCML_Supervised(basis=basis, n_basis=85, k_genuine=7, k_impostor=5, random_state=42) scml.fit(X, y) - csep = class_separation(scml.transform(X), y) - assert csep < 0.24 + after = class_separation(scml.transform(X), y) + assert before > after + 0.03 # It's better by a margin of 0.03 def test_big_n_features(self): X, y = make_classification(n_samples=100, n_classes=3, n_features=60, From 7a2a49dce8d6dc3e00aa8b53c49d9d97fce33f97 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Fri, 8 Oct 2021 15:43:29 +0200 Subject: [PATCH 15/40] Change min sklearn version to 0.21.3 (#334) --- .github/workflows/main.yml | 8 ++++---- README.rst | 2 +- doc/getting_started.rst | 2 +- setup.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 46e5d2c9..fbe91dea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,27 +10,27 @@ on: jobs: - # Checks compatibility with an old version of sklearn (0.20.3) + # Checks compatibility with an old version of sklearn (0.21.3) compatibility: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8'] + python-version: ['3.6', '3.7'] steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - - name: Run Tests with skggm + scikit-learn 0.20.3 + - name: Run Tests with skggm + scikit-learn 0.21.3 env: SKGGM_VERSION: a0ed406586c4364ea3297a658f415e13b5cbdaf8 run: | sudo apt-get install liblapack-dev pip install --upgrade pip pytest pip install wheel cython numpy scipy codecov pytest-cov - pip install scikit-learn==0.20.3 + pip install scikit-learn==0.21.3 pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION} pytest test --cov bash <(curl -s https://codecov.io/bash) diff --git a/README.rst b/README.rst index 681e29f6..b2f6e6d4 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ metric-learn contains efficient Python implementations of several popular superv - Python 3.6+ (the last version supporting Python 2 and Python 3.5 was `v0.5.0 `_) -- numpy, scipy, scikit-learn>=0.20.3 +- numpy>= 1.11.0, scipy>= 0.17.0, scikit-learn>=0.21.3 **Optional dependencies** diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 44fd1436..90b7c7ee 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -19,7 +19,7 @@ metric-learn can be installed in either of the following ways: - Python 3.6+ (the last version supporting Python 2 and Python 3.5 was `v0.5.0 `_) -- numpy, scipy, scikit-learn>=0.20.3 +- numpy>= 1.11.0, scipy>= 0.17.0, scikit-learn>=0.21.3 **Optional dependencies** diff --git a/setup.py b/setup.py index 8677e7bf..255671a2 100755 --- a/setup.py +++ b/setup.py @@ -63,9 +63,9 @@ ], packages=['metric_learn'], install_requires=[ - 'numpy', - 'scipy', - 'scikit-learn>=0.20.3', + 'numpy>= 1.11.0', + 'scipy>= 0.17.0', + 'scikit-learn>=0.21.3', ], extras_require=dict( docs=['sphinx', 'shinx_rtd_theme', 'numpydoc'], From 44be909b3d8d74072aeb9f96ad587bd1ca6b8cda Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Tue, 12 Oct 2021 11:21:04 -0300 Subject: [PATCH 16/40] Disallow 0 on Triplets predictions (#331) * Remove 3.9 from compatibility * Fix Triplets predict function. Made a test to show the point. * Fix identation * Simplified prediction as suggested * Resolved code review comments * Fix weird commit * Simplified assertion --- metric_learn/base_metric.py | 2 +- test/test_triplets_classifiers.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 721d7ba0..21506011 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -602,7 +602,7 @@ def predict(self, triplets): prediction : `numpy.ndarray` of floats, shape=(n_constraints,) Predictions of the ordering of pairs, for each triplet. """ - return np.sign(self.decision_function(triplets)) + return 2 * (self.decision_function(triplets) > 0) - 1 def decision_function(self, triplets): """Predicts differences between sample distances in input triplets. diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index 0f0bf7df..f2d5c015 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -6,6 +6,7 @@ from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np +from numpy.testing import assert_array_equal @pytest.mark.parametrize('with_preprocessor', [True, False]) @@ -26,6 +27,49 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, assert len(not_valid) == 0 +@pytest.mark.parametrize('estimator, build_dataset', triplets_learners, + ids=ids_triplets_learners) +def test_no_zero_prediction(estimator, build_dataset): + """ + Test that all predicted values are not zero, even when the + distance d(x,y) and d(x,z) is the same for a triplet of the + form (x, y, z). i.e border cases. + """ + triplets, _, _, X = build_dataset(with_preprocessor=False) + # Force 3 dimentions only, to use cross product and get easy orthogonal vec. + triplets = np.array([[t[0][:3], t[1][:3], t[2][:3]] for t in triplets]) + X = X[:, :3] + # Dummy fit + estimator = clone(estimator) + set_random_state(estimator) + estimator.fit(triplets) + # We force the transformation to be identity, to force euclidean distance + estimator.components_ = np.eye(X.shape[1]) + + # Get two orthogonal vectors in respect to X[1] + k = X[1] / np.linalg.norm(X[1]) # Normalize first vector + x = X[2] - X[2].dot(k) * k # Get random orthogonal vector + x /= np.linalg.norm(x) # Normalize + y = np.cross(k, x) # Get orthogonal vector to x + # Assert these orthogonal vectors are different + with pytest.raises(AssertionError): + assert_array_equal(X[1], x) + with pytest.raises(AssertionError): + assert_array_equal(X[1], y) + # Assert the distance is the same for both + assert estimator.get_metric()(X[1], x) == estimator.get_metric()(X[1], y) + + # Form the three scenarios where predict() gives 0 with numpy.sign + triplets_test = np.array( # Critical examples + [[X[0], X[2], X[2]], + [X[1], X[1], X[1]], + [X[1], x, y]]) + # Predict + predictions = estimator.predict(triplets_test) + # Check there are no zero values + assert np.sum(predictions == 0) == 0 + + @pytest.mark.parametrize('with_preprocessor', [True, False]) @pytest.mark.parametrize('estimator, build_dataset', triplets_learners, ids=ids_triplets_learners) From e2c3e921159bb0da48bfd4bafa096ffe7a7fb3f0 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Mon, 18 Oct 2021 05:30:21 -0300 Subject: [PATCH 17/40] Change SCML supervised docs (#337) --- metric_learn/scml.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index c3fde272..ee585018 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -473,13 +473,18 @@ class SCML_Supervised(_BaseSCML, TransformerMixin): Examples -------- - >>> from metric_learn import SCML - >>> triplets = np.array([[[1.2, 3.2], [2.3, 5.5], [2.1, 0.6]], - >>> [[4.5, 2.3], [2.1, 2.3], [7.3, 3.4]]]) - >>> scml = SCML(random_state=42) - >>> scml.fit(triplets) - SCML(beta=1e-5, B=None, max_iter=100000, verbose=False, - preprocessor=None, random_state=None) + >>> from metric_learn import SCML_Supervised + >>> from sklearn.datasets import load_iris + >>> iris_data = load_iris() + >>> X = iris_data['data'] + >>> Y = iris_data['target'] + >>> scml = SCML_Supervised(random_state=33) + >>> scml.fit(X, Y) + SCML_Supervised(random_state=33) + >>> scml.score_pairs([[X[0], X[1]], [X[0], X[2]]]) + array([1.84640733, 1.55984363]) + >>> scml.get_metric()(X[0], X[1]) + 1.8464073327922157 References ---------- From aaf8d44b8d31d6ea418f0bd80ef86958e5081b4c Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Thu, 21 Oct 2021 09:05:22 -0300 Subject: [PATCH 18/40] score_pairs refactor (#333) * Remove 3.9 from compatibility * First draft of refactoring BaseMetricLearner and Mahalanobis Learner * Avoid warning related to score_pairs deprecation in tests of pair_calibraiton * Minor fix * Replaced score_pairs with pair_distance in tests * Replace score_pairs with pair_distance inb docs. * Fix weird commit * Update classifiers to use pair_similarity * Updated rst docs * Fix identation * Update docs of score_pairs, get_metric * Add deprecation Test. Fix identation * Fixed changes requested 1 * Fixed changes requested 2 * Add equivalence test, p_dist == p_score * Fix tests and identation. * Fixed changes requested 3 * Fix identation * Last requested changes * Last small detail --- doc/introduction.rst | 23 ---- doc/supervised.rst | 20 +++- doc/weakly_supervised.rst | 30 +++-- metric_learn/base_metric.py | 210 +++++++++++++++++++++++++++++---- test/test_base_metric.py | 24 ++++ test/test_mahalanobis_mixin.py | 47 ++++++-- test/test_pairs_classifiers.py | 20 ++-- test/test_sklearn_compat.py | 13 +- test/test_utils.py | 79 +++++++++---- 9 files changed, 360 insertions(+), 106 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 7d9f52d0..e9ff0015 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -123,26 +123,3 @@ to the following resources: Survey `_ (2012) - **Book:** `Metric Learning `_ (2015) - -.. Methods [TO MOVE TO SUPERVISED/WEAK SECTIONS] -.. ============================================= - -.. Currently, each metric learning algorithm supports the following methods: - -.. - ``fit(...)``, which learns the model. -.. - ``get_mahalanobis_matrix()``, which returns a Mahalanobis matrix -.. - ``get_metric()``, which returns a function that takes as input two 1D - arrays and outputs the learned metric score on these two points -.. :math:`M = L^{\top}L` such that distance between vectors ``x`` and -.. ``y`` can be computed as :math:`\sqrt{\left(x-y\right)M\left(x-y\right)}`. -.. - ``components_from_metric(metric)``, which returns a transformation matrix -.. :math:`L \in \mathbb{R}^{D \times d}`, which can be used to convert a -.. data matrix :math:`X \in \mathbb{R}^{n \times d}` to the -.. :math:`D`-dimensional learned metric space :math:`X L^{\top}`, -.. in which standard Euclidean distances may be used. -.. - ``transform(X)``, which applies the aforementioned transformation. -.. - ``score_pairs(pairs)`` which returns the distance between pairs of -.. points. ``pairs`` should be a 3D array-like of pairs of shape ``(n_pairs, -.. 2, n_features)``, or it can be a 2D array-like of pairs indicators of -.. shape ``(n_pairs, 2)`` (see section :ref:`preprocessor_section` for more -.. details). \ No newline at end of file diff --git a/doc/supervised.rst b/doc/supervised.rst index c6d8b68b..e27b58ec 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -69,10 +69,10 @@ Also, as explained before, our metric learners has learn a distance between points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the - `score_pairs` function: + `pair_distance` function: ->>> nca.score_pairs([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]]]) -array([0.49627072, 3.65287282]) +>>> nca.pair_distance([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +array([0.49627072, 3.65287282, 6.06079877]) - Or you can return a function that will return the distance (in the new space) between two 1D arrays (the coordinates of the points in the original @@ -82,6 +82,18 @@ array([0.49627072, 3.65287282]) >>> metric_fun([3.5, 3.6], [5.6, 2.4]) 0.4962707194621285 +- Alternatively, you can use `pair_score` to return the **score** between + pairs of points (the larger the score, the more similar the pair). + For Mahalanobis learners, it is equal to the opposite of the distance. + +>>> score = nca.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score +array([-0.49627072, -3.65287282, -6.06079877]) + +This is useful because `pair_score` matches the **score** semantic of +scikit-learn's `Classification metrics +`_. + .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance @@ -93,7 +105,6 @@ array([0.49627072, 3.65287282]) array([[0.43680409, 0.89169412], [0.89169412, 1.9542479 ]]) -.. TODO: remove the "like it is the case etc..." if it's not the case anymore Scikit-learn compatibility -------------------------- @@ -105,6 +116,7 @@ All supervised algorithms are scikit-learn estimators scikit-learn model selection routines (`sklearn.model_selection.cross_val_score`, `sklearn.model_selection.GridSearchCV`, etc). +You can also use some of the scoring functions from `sklearn.metrics`. Algorithms ========== diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 174210b8..02ea4ef6 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -160,9 +160,9 @@ Also, as explained before, our metric learner has learned a distance between points. You can use this distance in two main ways: - You can either return the distance between pairs of points using the - `score_pairs` function: + `pair_distance` function: ->>> mmc.score_pairs([[[3.5, 3.6, 5.2], [5.6, 2.4, 6.7]], +>>> mmc.pair_distance([[[3.5, 3.6, 5.2], [5.6, 2.4, 6.7]], ... [[1.2, 4.2, 7.7], [2.1, 6.4, 0.9]]]) array([7.27607365, 0.88853014]) @@ -175,6 +175,18 @@ array([7.27607365, 0.88853014]) >>> metric_fun([3.5, 3.6, 5.2], [5.6, 2.4, 6.7]) 7.276073646278203 +- Alternatively, you can use `pair_score` to return the **score** between + pairs of points (the larger the score, the more similar the pair). + For Mahalanobis learners, it is equal to the opposite of the distance. + +>>> score = mmc.pair_score([[[3.5, 3.6], [5.6, 2.4]], [[1.2, 4.2], [2.1, 6.4]], [[3.3, 7.8], [10.9, 0.1]]]) +>>> score +array([-0.49627072, -3.65287282, -6.06079877]) + + This is useful because `pair_score` matches the **score** semantic of + scikit-learn's `Classification metrics + `_. + .. note:: If the metric learner that you use learns a :ref:`Mahalanobis distance @@ -187,8 +199,6 @@ array([[ 0.58603894, -5.69883982, -1.66614919], [-5.69883982, 55.41743549, 16.20219519], [-1.66614919, 16.20219519, 4.73697721]]) -.. TODO: remove the "like it is the case etc..." if it's not the case anymore - .. _sklearn_compat_ws: Prediction and scoring @@ -344,8 +354,8 @@ returns the `sklearn.metrics.roc_auc_score` (which is threshold-independent). .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, - `get_metric` and `get_mahalanobis_matrix`. + not specific to learning on pairs, like `transform`, `pair_distance`, + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. Algorithms ---------- @@ -691,8 +701,8 @@ of triplets that have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, - `get_metric` and `get_mahalanobis_matrix`. + not specific to learning on pairs, like `transform`, `pair_distance`, + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. @@ -859,8 +869,8 @@ of quadruplets have the right predicted ordering. .. note:: See :ref:`fit_ws` for more details on metric learners functions that are - not specific to learning on pairs, like `transform`, `score_pairs`, - `get_metric` and `get_mahalanobis_matrix`. + not specific to learning on pairs, like `transform`, `pair_distance`, + `pair_score`, `get_metric` and `get_mahalanobis_matrix`. diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 21506011..e7dbd608 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -9,6 +9,7 @@ import numpy as np from abc import ABCMeta, abstractmethod from ._util import ArrayIndexer, check_input, validate_vector +import warnings class BaseMetricLearner(BaseEstimator, metaclass=ABCMeta): @@ -27,13 +28,54 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): - """Returns the score between pairs + """ + .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_score`. + + .. warning:: + This method will be removed in 0.8.0. Please refer to `pair_distance` + or `pair_score`. This change will occur in order to add learners + that don't necessarily learn a Mahalanobis distance. + + Returns the score between pairs (can be a similarity, or a distance/metric depending on the algorithm) Parameters ---------- - pairs : `numpy.ndarray`, shape=(n_samples, 2, n_features) - 3D array of pairs. + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The score of every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference between `score_pairs` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. + """ + + @abstractmethod + def pair_score(self, pairs): + """ + .. versionadded:: 0.7.0 Compute the similarity score between pairs + + Returns the similarity score between pairs of points (the larger the score, + the more similar the pair). For metric learners that learn a distance, + the score is simply the opposite of the distance between pairs. All + learners have access to this method. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. Returns ------- @@ -43,10 +85,40 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_score` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. + """ + + @abstractmethod + def pair_distance(self, pairs): + """ + .. versionadded:: 0.7.0 Compute the distance between pairs + + Returns the (pseudo) distance between pairs, when available. For metric + learners that do not learn a (pseudo) distance, an error is thrown + instead. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs for which to compute the distance, with each + row corresponding to two points, for 2D array of indices of pairs + if the metric learner uses a preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The distance between every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `pair_distance` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. """ def _check_preprocessor(self): @@ -102,8 +174,10 @@ def _prepare_inputs(self, X, y=None, type_of_inputs='classic', @abstractmethod def get_metric(self): - """Returns a function that takes as input two 1D arrays and outputs the - learned metric score on these two points. + """Returns a function that takes as input two 1D arrays and outputs + the value of the learned metric on these two points. Depending on the + algorithm, it can return a distance or a similarity function between + pairs. This function will be independent from the metric learner that learned it (it will not be modified if the initial metric learner is modified), @@ -136,10 +210,17 @@ def get_metric(self): See Also -------- - score_pairs : a method that returns the metric score between several pairs - of points. Unlike `get_metric`, this is a method of the metric learner - and therefore can change if the metric learner changes. Besides, it can - use the metric learner's preprocessor, and works on concatenated arrays. + pair_distance : a method that returns the distance between several + pairs of points. Unlike `get_metric`, this is a method of the metric + learner and therefore can change if the metric learner changes. Besides, + it can use the metric learner's preprocessor, and works on concatenated + arrays. + + pair_score : a method that returns the similarity score between + several pairs of points. Unlike `get_metric`, this is a method of the + metric learner and therefore can change if the metric learner changes. + Besides, it can use the metric learner's preprocessor, and works on + concatenated arrays. """ @@ -182,13 +263,92 @@ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, """ def score_pairs(self, pairs): - r"""Returns the learned Mahalanobis distance between pairs. + r""" + .. deprecated:: 0.7.0 + This method is deprecated. Please use `pair_distance` instead. + + .. warning:: + This method will be removed in 0.8.0. Please refer to `pair_distance` + or `pair_score`. This change will occur in order to add learners + that don't necessarily learn a Mahalanobis distance. + + Returns the learned Mahalanobis distance between pairs. + + This distance is defined as: :math:`d_M(x, x') = \\sqrt{(x-x')^T M (x-x')}` + where ``M`` is the learned Mahalanobis matrix, for every pair of points + ``x`` and ``x'``. This corresponds to the euclidean distance between + embeddings of the points in a new space, obtained through a linear + transformation. Indeed, we have also: :math:`d_M(x, x') = \\sqrt{(x_e - + x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See + :class:`MahalanobisMixin`). + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The learned Mahalanobis distance for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `score_pairs` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. + + :ref:`mahalanobis_distances` : The section of the project documentation + that describes Mahalanobis Distances. + """ + dpr_msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + warnings.warn(dpr_msg, category=FutureWarning) + return self.pair_distance(pairs) + + def pair_score(self, pairs): + """ + Returns the opposite of the learned Mahalanobis distance between pairs. + + Parameters + ---------- + pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) + 3D Array of pairs to score, with each row corresponding to two points, + for 2D array of indices of pairs if the metric learner uses a + preprocessor. + + Returns + ------- + scores : `numpy.ndarray` of shape=(n_pairs,) + The opposite of the learned Mahalanobis distance for every pair. + + See Also + -------- + get_metric : a method that returns a function to compute the metric between + two points. The difference with `pair_score` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. + + :ref:`mahalanobis_distances` : The section of the project documentation + that describes Mahalanobis Distances. + """ + return -1 * self.pair_distance(pairs) + + def pair_distance(self, pairs): + """ + Returns the learned Mahalanobis distance between pairs. - This distance is defined as: :math:`d_M(x, x') = \sqrt{(x-x')^T M (x-x')}` + This distance is defined as: :math:`d_M(x, x') = \\sqrt{(x-x')^T M (x-x')}` where ``M`` is the learned Mahalanobis matrix, for every pair of points ``x`` and ``x'``. This corresponds to the euclidean distance between embeddings of the points in a new space, obtained through a linear - transformation. Indeed, we have also: :math:`d_M(x, x') = \sqrt{(x_e - + transformation. Indeed, we have also: :math:`d_M(x, x') = \\sqrt{(x_e - x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See :class:`MahalanobisMixin`). @@ -207,10 +367,10 @@ def score_pairs(self, pairs): See Also -------- get_metric : a method that returns a function to compute the metric between - two points. The difference with `score_pairs` is that it works on two 1D - arrays and cannot use a preprocessor. Besides, the returned function is - independent of the metric learner and hence is not modified if the metric - learner is. + two points. The difference with `pair_distance` is that it works on two + 1D arrays and cannot use a preprocessor. Besides, the returned function + is independent of the metric learner and hence is not modified if the + metric learner is. :ref:`mahalanobis_distances` : The section of the project documentation that describes Mahalanobis Distances. @@ -361,7 +521,7 @@ def decision_function(self, pairs): pairs = check_input(pairs, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return - self.score_pairs(pairs) + return self.pair_score(pairs) def score(self, pairs, y): """Computes score of pairs similarity prediction. @@ -631,8 +791,8 @@ def decision_function(self, triplets): triplets = check_input(triplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.score_pairs(triplets[:, [0, 2]]) - - self.score_pairs(triplets[:, :2])) + return (self.pair_score(triplets[:, :2]) - + self.pair_score(triplets[:, [0, 2]])) def score(self, triplets): """Computes score on input triplets. @@ -716,8 +876,8 @@ def decision_function(self, quadruplets): quadruplets = check_input(quadruplets, type_of_inputs='tuples', preprocessor=self.preprocessor_, estimator=self, tuple_size=self._tuple_size) - return (self.score_pairs(quadruplets[:, 2:]) - - self.score_pairs(quadruplets[:, :2])) + return (self.pair_score(quadruplets[:, :2]) - + self.pair_score(quadruplets[:, 2:])) def score(self, quadruplets): """Computes score on input quadruplets diff --git a/test/test_base_metric.py b/test/test_base_metric.py index 67f9b6a0..baa585b9 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -1,3 +1,4 @@ +from numpy.core.numeric import array_equal import pytest import re import unittest @@ -274,5 +275,28 @@ def test_n_components(estimator, build_dataset): 'Invalid n_components, must be in [1, {}]'.format(X.shape[1])) +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_score_pairs_warning(estimator, build_dataset): + """Tests that score_pairs returns a FutureWarning regarding deprecation. + Also that score_pairs and pair_distance have the same behaviour""" + input_data, labels, _, X = build_dataset() + model = clone(estimator) + set_random_state(model) + + # We fit the metric learner on it and then we call score_pairs on some + # points + model.fit(*remove_y(model, input_data, labels)) + + msg = ("score_pairs will be deprecated in release 0.7.0. " + "Use pair_score to compute similarity scores, or " + "pair_distances to compute distances.") + with pytest.warns(FutureWarning) as raised_warning: + score = model.score_pairs([[X[0], X[1]], ]) + dist = model.pair_distance([[X[0], X[1]], ]) + assert array_equal(score, dist) + assert any([str(warning.message) == msg for warning in raised_warning]) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index e3d981a4..e2aa1e4d 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -3,7 +3,8 @@ import pytest import numpy as np from numpy.linalg import LinAlgError -from numpy.testing import assert_array_almost_equal, assert_allclose +from numpy.testing import assert_array_almost_equal, assert_allclose, \ + assert_array_equal from scipy.spatial.distance import pdist, squareform, mahalanobis from scipy.stats import ortho_group from sklearn import clone @@ -27,7 +28,27 @@ @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_pairwise(estimator, build_dataset): +def test_pair_distance_pair_score_equivalent(estimator, build_dataset): + """ + For Mahalanobis learners, pair_score should be equivalent to the + opposite of the pair_distance result. + """ + input_data, labels, _, X = build_dataset() + n_samples = 20 + X = X[:n_samples] + model = clone(estimator) + set_random_state(model) + model.fit(*remove_y(estimator, input_data, labels)) + + distances = model.pair_distance(np.array(list(product(X, X)))) + scores = model.pair_score(np.array(list(product(X, X)))) + + assert_array_equal(distances, -1 * scores) + + +@pytest.mark.parametrize('estimator, build_dataset', metric_learners, + ids=ids_metric_learners) +def test_pair_distance_pairwise(estimator, build_dataset): # Computing pairwise scores should return a euclidean distance matrix. input_data, labels, _, X = build_dataset() n_samples = 20 @@ -36,7 +57,7 @@ def test_score_pairs_pairwise(estimator, build_dataset): set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) - pairwise = model.score_pairs(np.array(list(product(X, X))))\ + pairwise = model.pair_distance(np.array(list(product(X, X))))\ .reshape(n_samples, n_samples) check_is_distance_matrix(pairwise) @@ -51,8 +72,8 @@ def test_score_pairs_pairwise(estimator, build_dataset): @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_toy_example(estimator, build_dataset): - # Checks that score_pairs works on a toy example +def test_pair_distance_toy_example(estimator, build_dataset): + # Checks that pair_distance works on a toy example input_data, labels, _, X = build_dataset() n_samples = 20 X = X[:n_samples] @@ -64,24 +85,24 @@ def test_score_pairs_toy_example(estimator, build_dataset): distances = np.sqrt(np.sum((embedded_pairs[:, 1] - embedded_pairs[:, 0])**2, axis=-1)) - assert_array_almost_equal(model.score_pairs(pairs), distances) + assert_array_almost_equal(model.pair_distance(pairs), distances) @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_finite(estimator, build_dataset): +def test_pair_distance_finite(estimator, build_dataset): # tests that the score is finite input_data, labels, _, X = build_dataset() model = clone(estimator) set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) pairs = np.array(list(product(X, X))) - assert np.isfinite(model.score_pairs(pairs)).all() + assert np.isfinite(model.pair_distance(pairs)).all() @pytest.mark.parametrize('estimator, build_dataset', metric_learners, ids=ids_metric_learners) -def test_score_pairs_dim(estimator, build_dataset): +def test_pair_distance_dim(estimator, build_dataset): # scoring of 3D arrays should return 1D array (several tuples), # and scoring of 2D arrays (one tuple) should return an error (like # scikit-learn's error when scoring 1D arrays) @@ -90,13 +111,13 @@ def test_score_pairs_dim(estimator, build_dataset): set_random_state(model) model.fit(*remove_y(estimator, input_data, labels)) tuples = np.array(list(product(X, X))) - assert model.score_pairs(tuples).shape == (tuples.shape[0],) + assert model.pair_distance(tuples).shape == (tuples.shape[0],) context = make_context(estimator) msg = ("3D array of formed tuples expected{}. Found 2D array " "instead:\ninput={}. Reshape your data and/or use a preprocessor.\n" .format(context, tuples[1])) with pytest.raises(ValueError) as raised_error: - model.score_pairs(tuples[1]) + model.pair_distance(tuples[1]) assert str(raised_error.value) == msg @@ -140,7 +161,7 @@ def test_embed_dim(estimator, build_dataset): "instead:\ninput={}. Reshape your data and/or use a " "preprocessor.\n".format(context, X[0])) with pytest.raises(ValueError) as raised_error: - model.score_pairs(model.transform(X[0, :])) + model.pair_distance(model.transform(X[0, :])) assert str(raised_error.value) == err_msg # we test that the shape is also OK when doing dimensionality reduction if hasattr(model, 'n_components'): @@ -625,7 +646,7 @@ def test_singular_covariance_init_of_non_strict_pd(estimator, build_dataset): 'preprocessing step.') with pytest.warns(UserWarning) as raised_warning: model.fit(input_data, labels) - assert np.any([str(warning.message) == msg for warning in raised_warning]) + assert any([str(warning.message) == msg for warning in raised_warning]) M, _ = _initialize_metric_mahalanobis(X, init='covariance', random_state=RNG, return_inverse=True, diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 824bb622..714cbd08 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -49,14 +49,14 @@ def test_predict_monotonous(estimator, build_dataset, pairs_train, pairs_test, y_train, y_test = train_test_split(input_data, labels) estimator.fit(pairs_train, y_train) - distances = estimator.score_pairs(pairs_test) + scores = estimator.pair_score(pairs_test) predictions = estimator.predict(pairs_test) - min_dissimilar = np.min(distances[predictions == -1]) - max_similar = np.max(distances[predictions == 1]) - assert max_similar <= min_dissimilar - separator = np.mean([min_dissimilar, max_similar]) - assert (predictions[distances > separator] == -1).all() - assert (predictions[distances < separator] == 1).all() + max_dissimilar = np.max(scores[predictions == -1]) + min_similar = np.min(scores[predictions == 1]) + assert max_dissimilar <= min_similar + separator = np.mean([max_dissimilar, min_similar]) + assert (predictions[scores < separator] == -1).all() + assert (predictions[scores > separator] == 1).all() @pytest.mark.parametrize('with_preprocessor', [True, False]) @@ -65,15 +65,17 @@ def test_predict_monotonous(estimator, build_dataset, def test_raise_not_fitted_error_if_not_fitted(estimator, build_dataset, with_preprocessor): """Test that a NotFittedError is raised if someone tries to use - score_pairs, decision_function, get_metric, transform or + pair_score, score_pairs, decision_function, get_metric, transform or get_mahalanobis_matrix on input data and the metric learner has not been fitted.""" input_data, labels, preprocessor, _ = build_dataset(with_preprocessor) estimator = clone(estimator) estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) - with pytest.raises(NotFittedError): + with pytest.raises(NotFittedError): # Remove in 0.8.0 estimator.score_pairs(input_data) + with pytest.raises(NotFittedError): + estimator.pair_score(input_data) with pytest.raises(NotFittedError): estimator.decision_function(input_data) with pytest.raises(NotFittedError): diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index 3ad69712..b08fcf25 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -147,8 +147,19 @@ def test_array_like_inputs(estimator, build_dataset, with_preprocessor): pairs = np.array([[X[0], X[1]], [X[0], X[2]]]) pairs_variants, _ = generate_array_like(pairs) + + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + for pairs_variant in pairs_variants: - estimator.score_pairs(pairs_variant) + estimator.pair_score(pairs_variant) # All learners have pair_score + + # But not all of them will have pair_distance + try: + estimator.pair_distance(pairs_variant) + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg @pytest.mark.parametrize('with_preprocessor', [True, False]) diff --git a/test/test_utils.py b/test/test_utils.py index 072b94c5..83bdd86a 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -834,9 +834,9 @@ def test_error_message_tuple_size(estimator, _): @pytest.mark.parametrize('estimator, _', metric_learners, ids=ids_metric_learners) -def test_error_message_t_score_pairs(estimator, _): - """tests that if you want to score_pairs on triplets for instance, it returns - the right error message +def test_error_message_t_pair_distance_or_score(estimator, _): + """Tests that if you want to pair_distance or pair_score on triplets + for instance, it returns the right error message """ estimator = clone(estimator) set_random_state(estimator) @@ -844,12 +844,22 @@ def test_error_message_t_score_pairs(estimator, _): triplets = np.array([[[1.3, 6.3], [3., 6.8], [6.5, 4.4]], [[1.9, 5.3], [1., 7.8], [3.2, 1.2]]]) with pytest.raises(ValueError) as raised_err: - estimator.score_pairs(triplets) + estimator.pair_score(triplets) expected_msg = ("Tuples of 2 element(s) expected{}. Got tuples of 3 " "element(s) instead (shape=(2, 3, 2)):\ninput={}.\n" .format(make_context(estimator), triplets)) assert str(raised_err.value) == expected_msg + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + + # One exception will trigger for sure + with pytest.raises(Exception) as raised_exception: + estimator.pair_distance(triplets) + err_value = raised_exception.value.args[0] + assert err_value == expected_msg or err_value == not_implemented_msg + def test_preprocess_tuples_simple_example(): """Test the preprocessor on a very simple example of tuples to ensure the @@ -930,32 +940,59 @@ def test_same_with_or_without_preprocessor(estimator, build_dataset): method)(formed_test) assert np.array(output_with_prep == output_with_prep_formed).all() - # test score_pairs + # Test pair_score, all learners have it. idx1 = np.array([[0, 2], [5, 3]], dtype=int) - output_with_prep = estimator_with_preprocessor.score_pairs( + output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) - output_without_prep = estimator_without_preprocessor.score_pairs( + output_without_prep = estimator_without_preprocessor.pair_score( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - output_with_prep = estimator_with_preprocessor.score_pairs( + output_with_prep = estimator_with_preprocessor.pair_score( indicators_to_transform[idx1]) - output_without_prep = estimator_with_prep_formed.score_pairs( + output_without_prep = estimator_with_prep_formed.pair_score( formed_points_to_transform[idx1]) assert np.array(output_with_prep == output_without_prep).all() - # test transform - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_without_preprocessor.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() - - output_with_prep = estimator_with_preprocessor.transform( - indicators_to_transform) - output_without_prep = estimator_with_prep_formed.transform( - formed_points_to_transform) - assert np.array(output_with_prep == output_without_prep).all() + # Test pair_distance + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have pair_distance" + try: + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_without_preprocessor.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.pair_distance( + indicators_to_transform[idx1]) + output_without_prep = estimator_with_prep_formed.pair_distance( + formed_points_to_transform[idx1]) + assert np.array(output_with_prep == output_without_prep).all() + + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg + + # Test transform + not_implemented_msg = "" + # Todo in 0.7.0: Change 'not_implemented_msg' for the message that says + # "This learner does not have transform" + try: + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_without_preprocessor.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + output_with_prep = estimator_with_preprocessor.transform( + indicators_to_transform) + output_without_prep = estimator_with_prep_formed.transform( + formed_points_to_transform) + assert np.array(output_with_prep == output_without_prep).all() + + except Exception as raised_exception: + assert raised_exception.value.args[0] == not_implemented_msg def test_check_collapsed_pairs_raises_no_error(): From 6a4aaea62d35c173376fbd3e89b90e9efb0ebb16 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Thu, 21 Oct 2021 12:36:03 -0300 Subject: [PATCH 19/40] Fix 7 sources of warnings in the tests (#339) * Fix 7 sources of warnings * Fix indentation * Generalized warnings, as old sklearn throw more warnings * Changed np.any() for any() * Fix identation --- metric_learn/_util.py | 2 +- metric_learn/itml.py | 2 +- metric_learn/rca.py | 2 +- metric_learn/scml.py | 6 +++--- pytest.ini | 4 ++++ test/metric_learn_test.py | 8 ++++---- test/test_constraints.py | 21 ++++++++++++++++++--- test/test_sklearn_compat.py | 2 +- test/test_triplets_classifiers.py | 25 ++++++++++++++++++++++--- 9 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 pytest.ini diff --git a/metric_learn/_util.py b/metric_learn/_util.py index 764a34c8..868ececa 100644 --- a/metric_learn/_util.py +++ b/metric_learn/_util.py @@ -704,7 +704,7 @@ def _initialize_metric_mahalanobis(input, init='identity', random_state=None, elif init == 'covariance': if input.ndim == 3: # if the input are tuples, we need to form an X by deduplication - X = np.vstack({tuple(row) for row in input.reshape(-1, n_features)}) + X = np.unique(np.vstack(input), axis=0) else: X = input # atleast2d is necessary to deal with scalar covariance matrices diff --git a/metric_learn/itml.py b/metric_learn/itml.py index 43872b60..9fa3b75e 100644 --- a/metric_learn/itml.py +++ b/metric_learn/itml.py @@ -32,7 +32,7 @@ def _fit(self, pairs, y, bounds=None): type_of_inputs='tuples') # init bounds if bounds is None: - X = np.vstack({tuple(row) for row in pairs.reshape(-1, pairs.shape[2])}) + X = np.unique(np.vstack(pairs), axis=0) self.bounds_ = np.percentile(pairwise_distances(X), (5, 95)) else: bounds = check_array(bounds, allow_nd=False, ensure_min_samples=0, diff --git a/metric_learn/rca.py b/metric_learn/rca.py index 34f7f3ff..1da00062 100644 --- a/metric_learn/rca.py +++ b/metric_learn/rca.py @@ -112,7 +112,7 @@ def fit(self, X, chunks): # Fisher Linear Discriminant projection if dim < X.shape[1]: total_cov = np.cov(X[chunk_mask], rowvar=0) - tmp = np.linalg.lstsq(total_cov, inner_cov)[0] + tmp = np.linalg.lstsq(total_cov, inner_cov, rcond=None)[0] vals, vecs = np.linalg.eig(tmp) inds = np.argsort(vals)[:dim] A = vecs[:, inds] diff --git a/metric_learn/scml.py b/metric_learn/scml.py index ee585018..b86c6fe1 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -615,10 +615,10 @@ def _generate_bases_LDA(self, X, y): k_class = np.vstack((np.minimum(class_count, scales[0]), np.minimum(class_count, scales[1]))) - idx_set = [np.zeros((n_clusters, sum(k_class[0, :])), dtype=np.int), - np.zeros((n_clusters, sum(k_class[1, :])), dtype=np.int)] + idx_set = [np.zeros((n_clusters, sum(k_class[0, :])), dtype=np.int64), + np.zeros((n_clusters, sum(k_class[1, :])), dtype=np.int64)] - start_finish_indices = np.hstack((np.zeros((2, 1), np.int), + start_finish_indices = np.hstack((np.zeros((2, 1), np.int64), k_class)).cumsum(axis=1) neigh = NearestNeighbors() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..ef3c8acb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +markers = + integration: mark a test as integration + unit: mark a test as unit \ No newline at end of file diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 2debe426..542e1e0a 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -9,7 +9,6 @@ make_spd_matrix) from numpy.testing import (assert_array_almost_equal, assert_array_equal, assert_allclose) -from metric_learn.sklearn_shims import assert_warns_message from sklearn.exceptions import ConvergenceWarning from sklearn.utils.validation import check_X_y from sklearn.preprocessing import StandardScaler @@ -1143,9 +1142,10 @@ def test_convergence_warning(dataset, algo_class): X, y = dataset model = algo_class(max_iter=2, verbose=True) cls_name = model.__class__.__name__ - assert_warns_message(ConvergenceWarning, - '[{}] {} did not converge'.format(cls_name, cls_name), - model.fit, X, y) + msg = '[{}] {} did not converge'.format(cls_name, cls_name) + with pytest.warns(Warning) as raised_warning: + model.fit(X, y) + assert any([msg in str(warn.message) for warn in raised_warning]) if __name__ == '__main__': diff --git a/test/test_constraints.py b/test/test_constraints.py index 92876779..def228d4 100644 --- a/test/test_constraints.py +++ b/test/test_constraints.py @@ -103,7 +103,7 @@ def test_generate_knntriplets_under_edge(k_genuine, k_impostor, T_test): @pytest.mark.parametrize("k_genuine, k_impostor,", - [(2, 3), (3, 3), (2, 4), (3, 4)]) + [(3, 3), (2, 4), (3, 4), (10, 9), (144, 33)]) def test_generate_knntriplets(k_genuine, k_impostor): """Checks edge and over the edge cases of knn triplet construction with not enough neighbors""" @@ -118,8 +118,23 @@ def test_generate_knntriplets(k_genuine, k_impostor): X = np.array([[0, 0], [2, 2], [4, 4], [8, 8], [16, 16], [32, 32], [33, 33]]) y = np.array([1, 1, 1, 2, 2, 2, -1]) - T = Constraints(y).generate_knntriplets(X, k_genuine, k_impostor) - + msg1 = ("The class 1 has 3 elements, which is not sufficient to " + f"generate {k_genuine+1} genuine neighbors " + "as specified by k_genuine") + msg2 = ("The class 2 has 3 elements, which is not sufficient to " + f"generate {k_genuine+1} genuine neighbors " + "as specified by k_genuine") + msg3 = ("The class 1 has 3 elements of other classes, which is " + f"not sufficient to generate {k_impostor} impostor " + "neighbors as specified by k_impostor") + msg4 = ("The class 2 has 3 elements of other classes, which is " + f"not sufficient to generate {k_impostor} impostor " + "neighbors as specified by k_impostor") + msgs = [msg1, msg2, msg3, msg4] + with pytest.warns(UserWarning) as user_warning: + T = Constraints(y).generate_knntriplets(X, k_genuine, k_impostor) + assert any([[msg in str(warn.message) for msg in msgs] + for warn in user_warning]) assert np.array_equal(sorted(T.tolist()), T_test) diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index b08fcf25..a23a88d0 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -235,7 +235,7 @@ def test_cross_validation_manual_vs_scikit(estimator, build_dataset, n_splits = 3 kfold = KFold(shuffle=False, n_splits=n_splits) n_samples = input_data.shape[0] - fold_sizes = (n_samples // n_splits) * np.ones(n_splits, dtype=np.int) + fold_sizes = (n_samples // n_splits) * np.ones(n_splits, dtype=np.int64) fold_sizes[:n_samples % n_splits] += 1 current = 0 scores, predictions = [], np.zeros(input_data.shape[0]) diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index f2d5c015..600947e6 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -1,6 +1,7 @@ import pytest from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split +import metric_learn from test.test_utils import triplets_learners, ids_triplets_learners from metric_learn.sklearn_shims import set_random_state @@ -20,7 +21,13 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) triplets_train, triplets_test = train_test_split(input_data) - estimator.fit(triplets_train) + if isinstance(estimator, metric_learn.SCML): + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + estimator.fit(triplets_train) + assert msg in str(raised_warning[0].message) + else: + estimator.fit(triplets_train) predictions = estimator.predict(triplets_test) not_valid = [e for e in predictions if e not in [-1, 1]] @@ -42,7 +49,13 @@ def test_no_zero_prediction(estimator, build_dataset): # Dummy fit estimator = clone(estimator) set_random_state(estimator) - estimator.fit(triplets) + if isinstance(estimator, metric_learn.SCML): + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + estimator.fit(triplets) + assert msg in str(raised_warning[0].message) + else: + estimator.fit(triplets) # We force the transformation to be identity, to force euclidean distance estimator.components_ = np.eye(X.shape[1]) @@ -93,7 +106,13 @@ def test_accuracy_toy_example(estimator, build_dataset): triplets, _, _, X = build_dataset(with_preprocessor=False) estimator = clone(estimator) set_random_state(estimator) - estimator.fit(triplets) + if isinstance(estimator, metric_learn.SCML): + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + estimator.fit(triplets) + assert msg in str(raised_warning[0].message) + else: + estimator.fit(triplets) # We take the two first points and we build 4 regularly spaced points on the # line they define, so that it's easy to build triplets of different # similarities. From bdfdb2487b065fd088224a14808c395430a21be0 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Tue, 2 Nov 2021 15:37:51 +0100 Subject: [PATCH 20/40] Assigned n_basis to SCML when needed for tests. Catch warn when needed as well. (#341) --- test/metric_learn_test.py | 31 +++++++++++++++++++++++-------- test/test_mahalanobis_mixin.py | 4 ++-- test/test_sklearn_compat.py | 5 ++++- test/test_triplets_classifiers.py | 25 +++---------------------- test/test_utils.py | 4 ++-- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index 542e1e0a..fe1560c2 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -95,7 +95,7 @@ def test_big_n_features(self): n_informative=60, n_redundant=0, n_repeated=0, random_state=42) X = StandardScaler().fit_transform(X) - scml = SCML_Supervised(random_state=42) + scml = SCML_Supervised(random_state=42, n_basis=399) scml.fit(X, y) csep = class_separation(scml.transform(X), y) assert csep < 0.7 @@ -106,7 +106,7 @@ def test_big_n_features(self): [2, 0], [2, 1]]), np.array([1, 0, 1, 0])))]) def test_bad_basis(self, estimator, data): - model = estimator(basis='bad_basis') + model = estimator(basis='bad_basis', n_basis=33) # n_basis doesn't matter msg = ("`basis` must be one of the options '{}' or an array of shape " "(n_basis, n_features)." .format("', '".join(model._authorized_basis))) @@ -238,16 +238,23 @@ def test_lda_toy(self): @pytest.mark.parametrize('n_features', [10, 50, 100]) @pytest.mark.parametrize('n_classes', [5, 10, 15]) def test_triplet_diffs(self, n_samples, n_features, n_classes): + """ + Test that the correct value of n_basis is being generated with + different triplet constraints. + """ X, y = make_classification(n_samples=n_samples, n_classes=n_classes, n_features=n_features, n_informative=n_features, n_redundant=0, n_repeated=0) X = StandardScaler().fit_transform(X) - - model = SCML_Supervised() + model = SCML_Supervised(n_basis=None) # Explicit n_basis=None constraints = Constraints(y) triplets = constraints.generate_knntriplets(X, model.k_genuine, model.k_impostor) - basis, n_basis = model._generate_bases_dist_diff(triplets, X) + + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + basis, n_basis = model._generate_bases_dist_diff(triplets, X) + assert msg in str(raised_warning[0].message) expected_n_basis = n_features * 80 assert n_basis == expected_n_basis @@ -257,13 +264,21 @@ def test_triplet_diffs(self, n_samples, n_features, n_classes): @pytest.mark.parametrize('n_features', [10, 50, 100]) @pytest.mark.parametrize('n_classes', [5, 10, 15]) def test_lda(self, n_samples, n_features, n_classes): + """ + Test that when n_basis=None, the correct n_basis is generated, + for SCML_Supervised and different values of n_samples, n_features + and n_classes. + """ X, y = make_classification(n_samples=n_samples, n_classes=n_classes, n_features=n_features, n_informative=n_features, n_redundant=0, n_repeated=0) X = StandardScaler().fit_transform(X) - model = SCML_Supervised() - basis, n_basis = model._generate_bases_LDA(X, y) + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + model = SCML_Supervised(n_basis=None) # Explicit n_basis=None + basis, n_basis = model._generate_bases_LDA(X, y) + assert msg in str(raised_warning[0].message) num_eig = min(n_classes - 1, n_features) expected_n_basis = min(20 * n_features, n_samples * 2 * num_eig - 1) @@ -299,7 +314,7 @@ def test_int_inputs_supervised(self, name): assert msg == raised_error.value.args[0] def test_large_output_iter(self): - scml = SCML(max_iter=1, output_iter=2) + scml = SCML(max_iter=1, output_iter=2, n_basis=33) # n_basis don't matter triplets = np.array([[[0, 1], [2, 1], [0, 0]]]) msg = ("The value of output_iter must be equal or smaller than" " max_iter.") diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index e2aa1e4d..e69aa032 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -291,8 +291,8 @@ def test_components_is_2D(estimator, build_dataset): model.fit(*remove_y(estimator, input_data, labels)) assert model.components_.shape == (X.shape[1], X.shape[1]) - # test that it works for 1 feature - trunc_data = input_data[..., :1] + # test that it works for 1 feature. Use 2nd dimention, to avoid border cases + trunc_data = input_data[..., 1:2] # we drop duplicates that might have been formed, i.e. of the form # aabc or abcc or aabb for quadruplets, and aa for pairs. diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index a23a88d0..d2369b1c 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -79,7 +79,10 @@ def test_rca(self): check_estimator(Stable_RCA_Supervised()) def test_scml(self): - check_estimator(SCML_Supervised()) + msg = "As no value for `n_basis` was selected, " + with pytest.warns(UserWarning) as raised_warning: + check_estimator(SCML_Supervised()) + assert msg in str(raised_warning[0].message) RNG = check_random_state(0) diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index 600947e6..f2d5c015 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -1,7 +1,6 @@ import pytest from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split -import metric_learn from test.test_utils import triplets_learners, ids_triplets_learners from metric_learn.sklearn_shims import set_random_state @@ -21,13 +20,7 @@ def test_predict_only_one_or_minus_one(estimator, build_dataset, estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) triplets_train, triplets_test = train_test_split(input_data) - if isinstance(estimator, metric_learn.SCML): - msg = "As no value for `n_basis` was selected, " - with pytest.warns(UserWarning) as raised_warning: - estimator.fit(triplets_train) - assert msg in str(raised_warning[0].message) - else: - estimator.fit(triplets_train) + estimator.fit(triplets_train) predictions = estimator.predict(triplets_test) not_valid = [e for e in predictions if e not in [-1, 1]] @@ -49,13 +42,7 @@ def test_no_zero_prediction(estimator, build_dataset): # Dummy fit estimator = clone(estimator) set_random_state(estimator) - if isinstance(estimator, metric_learn.SCML): - msg = "As no value for `n_basis` was selected, " - with pytest.warns(UserWarning) as raised_warning: - estimator.fit(triplets) - assert msg in str(raised_warning[0].message) - else: - estimator.fit(triplets) + estimator.fit(triplets) # We force the transformation to be identity, to force euclidean distance estimator.components_ = np.eye(X.shape[1]) @@ -106,13 +93,7 @@ def test_accuracy_toy_example(estimator, build_dataset): triplets, _, _, X = build_dataset(with_preprocessor=False) estimator = clone(estimator) set_random_state(estimator) - if isinstance(estimator, metric_learn.SCML): - msg = "As no value for `n_basis` was selected, " - with pytest.warns(UserWarning) as raised_warning: - estimator.fit(triplets) - assert msg in str(raised_warning[0].message) - else: - estimator.fit(triplets) + estimator.fit(triplets) # We take the two first points and we build 4 regularly spaced points on the # line they define, so that it's easy to build triplets of different # similarities. diff --git a/test/test_utils.py b/test/test_utils.py index 83bdd86a..f3000344 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -117,7 +117,7 @@ def build_quadruplets(with_preprocessor=False): [learner for (learner, _) in quadruplets_learners])) -triplets_learners = [(SCML(), build_triplets)] +triplets_learners = [(SCML(n_basis=320), build_triplets)] ids_triplets_learners = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in triplets_learners])) @@ -140,7 +140,7 @@ def build_quadruplets(with_preprocessor=False): (RCA_Supervised(num_chunks=5), build_classification), (SDML_Supervised(prior='identity', balance_param=1e-5), build_classification), - (SCML_Supervised(), build_classification)] + (SCML_Supervised(n_basis=80), build_classification)] ids_classifiers = list(map(lambda x: x.__class__.__name__, [learner for (learner, _) in classifiers])) From 964f28d79a298bb16c2e930789d1f0f4833141f3 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Tue, 2 Nov 2021 17:03:12 +0100 Subject: [PATCH 21/40] Threshold must be a real number (#322) * Add venv to gitignore * Check if threshold is a real value * Simplified threshold type-check * Follow linter rules * Fix last linter error * Add test to check correct behaviour. Sacrified simplicity for the bool case. * Update test. Stick to custom message. It's bool permissive * Explicit boolean permissive case in test * Changed isinstance for custom ValueError message * TypeError for most input. ValueError for String case. --- metric_learn/base_metric.py | 10 ++++++++-- test/test_pairs_classifiers.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index e7dbd608..9064c100 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -569,8 +569,14 @@ def set_threshold(self, threshold): The pairs classifier with the new threshold set. """ check_is_fitted(self, 'preprocessor_') - - self.threshold_ = threshold + try: + self.threshold_ = float(threshold) + except TypeError: + raise ValueError('Parameter threshold must be a real number. ' + 'Got {} instead.'.format(type(threshold))) + except ValueError: + raise ValueError('Parameter threshold must be a real number. ' + 'Got {} instead.'.format(type(threshold))) return self def calibrate_threshold(self, pairs_valid, y_valid, strategy='accuracy', diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 714cbd08..6a725f23 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -180,6 +180,25 @@ def test_set_threshold(): assert identity_pairs_classifier.threshold_ == 0.5 +@pytest.mark.parametrize('value', ["ABC", None, [1, 2, 3], {'key': None}, + (1, 2), set(), + np.array([[[0.], [1.]], [[1.], [3.]]])]) +def test_set_wrong_type_threshold(value): + """ + Test that `set_threshold` indeed sets the threshold + and cannot accept nothing but float or integers, but + being permissive with boolean True=1.0 and False=0.0 + """ + model = IdentityPairsClassifier() + model.fit(np.array([[[0.], [1.]]]), np.array([1])) + msg = ('Parameter threshold must be a real number. ' + 'Got {} instead.'.format(type(value))) + + with pytest.raises(ValueError) as e: # String + model.set_threshold(value) + assert str(e.value).startswith(msg) + + def test_f_beta_1_is_f_1(): # test that putting beta to 1 indeed finds the best threshold to optimize # the f1_score From a797635b8050ecad29f6484eb2d44bafe5fabceb Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Thu, 11 Nov 2021 06:21:00 +0100 Subject: [PATCH 22/40] [Minor] GUI for matplotlib to run examples (#323) * Proposal to add pyqt5 as GUI to run examples correctly * Add note about matplotlib gui backend instead --- examples/plot_metric_learning_examples.py | 4 ++++ examples/plot_sandwich.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/examples/plot_metric_learning_examples.py b/examples/plot_metric_learning_examples.py index 71229554..d03242b2 100644 --- a/examples/plot_metric_learning_examples.py +++ b/examples/plot_metric_learning_examples.py @@ -15,7 +15,11 @@ ###################################################################### # Imports # ^^^^^^^ +# .. note:: # +# In order to show the charts of the examples you need a graphical +# ``matplotlib`` backend installed. For intance, use ``pip install pyqt5`` +# to get Qt graphical interface or use your favorite one. from sklearn.manifold import TSNE diff --git a/examples/plot_sandwich.py b/examples/plot_sandwich.py index d5856667..07ab78e9 100644 --- a/examples/plot_sandwich.py +++ b/examples/plot_sandwich.py @@ -6,6 +6,13 @@ Sandwich demo based on code from http://nbviewer.ipython.org/6576096 """ +###################################################################### +# .. note:: +# +# In order to show the charts of the examples you need a graphical +# ``matplotlib`` backend installed. For intance, use ``pip install pyqt5`` +# to get Qt graphical interface or use your favorite one. + import numpy as np from matplotlib import pyplot as plt from sklearn.metrics import pairwise_distances From 4e0c4442329ebe12a7e2a31598fc13ae053fd6b6 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Wed, 17 Nov 2021 14:54:36 +0100 Subject: [PATCH 23/40] [MRG][DOC] Fixes almost all warnings in the docs (#338) * Update API names, unuse depretaed html4 * Fixes a lot of warning. Add Methods doctree * More warnings solved * Fix docs dependencies * New style for Example Code and References * Add all Methods to all classes in docstrings, in alphabetical order * Add MetricTransformer and MahalanobisMixin to auto-docs * Delete unused vars in docs. Use simple quotes * Fix identation * Fix Github CI instead of old Travis CI * References Lists are now numbered * RemoveExample Code body almost everywhere * Removed Methods directive. Kept warnings * Deprecated directive now is red as in sklearn --- doc/_static/css/styles.css | 36 +++++++++ doc/conf.py | 11 +-- doc/index.rst | 6 +- doc/metric_learn.rst | 2 + doc/supervised.rst | 56 +++++++------- doc/unsupervised.rst | 9 ++- doc/weakly_supervised.rst | 93 +++++++++++++---------- examples/plot_metric_learning_examples.py | 6 +- metric_learn/base_metric.py | 38 +++++---- metric_learn/constraints.py | 14 ++-- metric_learn/covariance.py | 4 + metric_learn/itml.py | 2 +- metric_learn/lfda.py | 2 +- metric_learn/rca.py | 2 +- metric_learn/scml.py | 8 +- setup.py | 3 +- 16 files changed, 175 insertions(+), 117 deletions(-) create mode 100644 doc/_static/css/styles.css diff --git a/doc/_static/css/styles.css b/doc/_static/css/styles.css new file mode 100644 index 00000000..6d350ae4 --- /dev/null +++ b/doc/_static/css/styles.css @@ -0,0 +1,36 @@ +.hatnote { + border-color: #e1e4e5 ; + border-style: solid ; + border-width: 1px ; + font-size: x-small ; + font-style: italic ; + margin-left: auto ; + margin-right: auto ; + margin-bottom: 24px; + padding: 12px; +} +.hatnote-gray { + background-color: #f5f5f5 +} +.hatnote li { + list-style-type: square; + margin-left: 12px !important; +} +.hatnote ul { + list-style-type: square; + margin-left: 0px !important; + margin-bottom: 0px !important; +} +.deprecated { + color: #b94a48; + background-color: #F3E5E5; + border-color: #eed3d7; + margin-top: 0.5rem; + padding: 0.5rem; + border-radius: 0.5rem; + margin-bottom: 0.5rem; +} + +.deprecated p { + margin-bottom: 0 !important; +} \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index 94263c7a..5eb312dc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -38,9 +38,6 @@ html_static_path = ['_static'] htmlhelp_basename = 'metric-learndoc' -# Option to only need single backticks to refer to symbols -default_role = 'any' - # Option to hide doctests comments in the documentation (like # doctest: # +NORMALIZE_WHITESPACE for instance) trim_doctest_flags = True @@ -67,10 +64,6 @@ # generate autosummary even if no references autosummary_generate = True -# Switch to old behavior with html4, for a good display of references, -# as described in https://github.com/sphinx-doc/sphinx/issues/6705 -html4_writer = True - # Temporary work-around for spacing problem between parameter and parameter # type in the doc, see https://github.com/numpy/numpydoc/issues/215. The bug @@ -79,8 +72,8 @@ # In an ideal world, this would get fixed in this PR: # https://github.com/readthedocs/sphinx_rtd_theme/pull/747/files def setup(app): - app.add_javascript('js/copybutton.js') - app.add_stylesheet("basic.css") + app.add_js_file('js/copybutton.js') + app.add_css_file('css/styles.css') # Remove matplotlib agg warnings from generated doc when using plt.show diff --git a/doc/index.rst b/doc/index.rst index 6ec4fb26..f9dfd83d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,6 +1,6 @@ metric-learn: Metric Learning in Python ======================================= -|Travis-CI Build Status| |License| |PyPI version| |Code coverage| +|GitHub Actions Build Status| |License| |PyPI version| |Code coverage| `metric-learn `_ contains efficient Python implementations of several popular supervised and @@ -57,8 +57,8 @@ Documentation outline :ref:`genindex` | :ref:`search` -.. |Travis-CI Build Status| image:: https://api.travis-ci.org/scikit-learn-contrib/metric-learn.svg?branch=master - :target: https://travis-ci.org/scikit-learn-contrib/metric-learn +.. |GitHub Actions Build Status| image:: https://github.com/scikit-learn-contrib/metric-learn/workflows/CI/badge.svg + :target: https://github.com/scikit-learn-contrib/metric-learn/actions?query=event%3Apush+branch%3Amaster .. |PyPI version| image:: https://badge.fury.io/py/metric-learn.svg :target: http://badge.fury.io/py/metric-learn .. |License| image:: http://img.shields.io/:license-mit-blue.svg?style=flat diff --git a/doc/metric_learn.rst b/doc/metric_learn.rst index 8f91d91c..4d0676b9 100644 --- a/doc/metric_learn.rst +++ b/doc/metric_learn.rst @@ -13,6 +13,8 @@ Base Classes metric_learn.Constraints metric_learn.base_metric.BaseMetricLearner + metric_learn.base_metric.MetricTransformer + metric_learn.base_metric.MahalanobisMixin metric_learn.base_metric._PairsClassifierMixin metric_learn.base_metric._TripletsClassifierMixin metric_learn.base_metric._QuadrupletsClassifierMixin diff --git a/doc/supervised.rst b/doc/supervised.rst index e27b58ec..09077dc2 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -152,7 +152,7 @@ neighbors (with same labels) of :math:`\mathbf{x}_{i}`, :math:`y_{ij}=0` indicates :math:`\mathbf{x}_{i}, \mathbf{x}_{j}` belong to different classes, :math:`[\cdot]_+=\max(0, \cdot)` is the Hinge loss. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -167,15 +167,15 @@ indicates :math:`\mathbf{x}_{i}, \mathbf{x}_{j}` belong to different classes, lmnn = LMNN(k=5, learn_rate=1e-6) lmnn.fit(X, Y, verbose=False) -.. topic:: References: +.. rubric:: References - .. [1] Weinberger et al. `Distance Metric Learning for Large Margin - Nearest Neighbor Classification - `_. - JMLR 2009 - .. [2] `Wikipedia entry on Large Margin Nearest Neighbor `_ - +.. container:: hatnote hatnote-gray + + [1]. Weinberger et al. `Distance Metric Learning for Large Margin Nearest Neighbor Classification `_. JMLR 2009. + + [2]. `Wikipedia entry on Large Margin Nearest Neighbor `_. + .. _nca: @@ -216,7 +216,7 @@ the sum of probability of being correctly classified: \mathbf{L} = \text{argmax}\sum_i p_i -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -231,13 +231,14 @@ the sum of probability of being correctly classified: nca = NCA(max_iter=1000) nca.fit(X, Y) -.. topic:: References: +.. rubric:: References + + +.. container:: hatnote hatnote-gray - .. [1] Goldberger et al. - `Neighbourhood Components Analysis `_. - NIPS 2005 + [1]. Goldberger et al. `Neighbourhood Components Analysis `_. NIPS 2005. - .. [2] `Wikipedia entry on Neighborhood Components Analysis `_ + [2]. `Wikipedia entry on Neighborhood Components Analysis `_. .. _lfda: @@ -289,7 +290,7 @@ nearby data pairs in the same class are made close and the data pairs in different classes are separated from each other; far apart data pairs in the same class are not imposed to be close. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -309,15 +310,14 @@ same class are not imposed to be close. To work around this, fit instances of this class to data once, then keep the instance around to do transformations. -.. topic:: References: +.. rubric:: References - .. [1] Sugiyama. `Dimensionality Reduction of Multimodal Labeled Data by Local - Fisher Discriminant Analysis `_. - JMLR 2007 - .. [2] Tang. `Local Fisher Discriminant Analysis on Beer Style Clustering - `_. +.. container:: hatnote hatnote-gray + + [1]. Sugiyama. `Dimensionality Reduction of Multimodal Labeled Data by Local Fisher Discriminant Analysis `_. JMLR 2007. + + [2]. Tang. `Local Fisher Discriminant Analysis on Beer Style Clustering `_. .. _mlkr: @@ -363,7 +363,7 @@ calculating a weighted average of all the training samples: \hat{y}_i = \frac{\sum_{j\neq i}y_jk_{ij}}{\sum_{j\neq i}k_{ij}} -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -377,10 +377,12 @@ calculating a weighted average of all the training samples: mlkr = MLKR() mlkr.fit(X, Y) -.. topic:: References: +.. rubric:: References + + +.. container:: hatnote hatnote-gray - .. [1] Weinberger et al. `Metric Learning for Kernel Regression `_. AISTATS 2007 + [1]. Weinberger et al. `Metric Learning for Kernel Regression `_. AISTATS 2007. .. _supervised_version: @@ -417,7 +419,7 @@ quadruplets, where for each quadruplet the two first points are from the same class, and the two last points are from a different class (so indeed the two last points should be less similar than the two first points). -.. topic:: Example Code: +.. rubric:: Example Code :: diff --git a/doc/unsupervised.rst b/doc/unsupervised.rst index 1191e805..110b07f9 100644 --- a/doc/unsupervised.rst +++ b/doc/unsupervised.rst @@ -20,7 +20,7 @@ It can be used for ZCA whitening of the data (see the Wikipedia page of `whitening transformation `_). -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -32,6 +32,9 @@ Whitening_transformation>`_). cov = Covariance().fit(iris) x = cov.transform(iris) -.. topic:: References: +.. rubric:: References - .. [1] On the Generalized Distance in Statistics, P.C.Mahalanobis, 1936 \ No newline at end of file + +.. container:: hatnote hatnote-gray + + [1]. On the Generalized Distance in Statistics, P.C.Mahalanobis, 1936. \ No newline at end of file diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 02ea4ef6..341d959a 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -62,8 +62,9 @@ The most intuitive way to represent tuples is to provide the algorithm with a in a tuple (2 for pairs, 3 for triplets for instance), and `n_features` is the number of features of each point. -.. topic:: Example: - Here is an artificial dataset of 4 pairs of 2 points of 3 features each: +.. rubric:: Example Code + +Here is an artificial dataset of 4 pairs of 2 points of 3 features each: >>> import numpy as np >>> tuples = np.array([[[-0.12, -1.21, -0.20], @@ -94,7 +95,9 @@ would be to keep the dataset of points `X` aside, and just represent tuples as a collection of tuples of *indices* from the points in `X`. Since we loose the feature dimension there, the resulting array is 2D. -.. topic:: Example: An equivalent representation of the above pairs would be: +.. rubric:: Example Code + +An equivalent representation of the above pairs would be: >>> X = np.array([[-0.12, -1.21, -0.20], >>> [+0.05, -0.19, -0.05], @@ -410,7 +413,7 @@ for similar and dissimilar pairs respectively, and :math:`\mathbf{M}_0` is the prior distance metric, set to identity matrix by default, :math:`D_{\ell \mathrm{d}}(\cdot)` is the log determinant. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -429,11 +432,14 @@ is the prior distance metric, set to identity matrix by default, itml = ITML() itml.fit(pairs, y) -.. topic:: References: +.. rubric:: References + - .. [1] Jason V. Davis, et al. `Information-theoretic Metric Learning `_. ICML 2007 +.. container:: hatnote hatnote-gray - .. [2] Adapted from Matlab code at http://www.cs.utexas.edu/users/pjain/itml/ + [1]. Jason V. Davis, et al. `Information-theoretic Metric Learning `_. ICML 2007. + + [2]. Adapted from Matlab code at http://www.cs.utexas.edu/users/pjain/itml/ . .. _sdml: @@ -468,7 +474,7 @@ the sums of the row elements of :math:`\mathbf{K}`., :math:`||\cdot||_{1, off}` is the off-diagonal L1 norm. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -486,19 +492,19 @@ is the off-diagonal L1 norm. sdml = SDML() sdml.fit(pairs, y) -.. topic:: References: +.. rubric:: References + - .. [1] Qi et al. - `An efficient sparse metric learning in high-dimensional space via - L1-penalized log-determinant regularization `_. - ICML 2009. +.. container:: hatnote hatnote-gray - .. [2] Code adapted from https://gist.github.com/kcarnold/5439945 + [1]. Qi et al. `An efficient sparse metric learning in high-dimensional space via L1-penalized log-determinant regularization `_. ICML 2009. + + [2]. Code adapted from https://gist.github.com/kcarnold/5439945 . .. _rca: :py:class:`RCA ` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Relative Components Analysis (:py:class:`RCA `) @@ -522,7 +528,7 @@ where chunklet :math:`j` consists of :math:`\{\mathbf{x}_{ji}\}_{i=1}^{n_j}` with a mean :math:`\hat{m}_j`. The inverse of :math:`\mathbf{C}^{-1}` is used as the Mahalanobis matrix. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -537,15 +543,16 @@ as the Mahalanobis matrix. rca = RCA() rca.fit(X, chunks) -.. topic:: References: +.. rubric:: References + - .. [1] Shental et al. `Adjustment learning and relevant component analysis - `_. ECCV 2002 +.. container:: hatnote hatnote-gray - .. [2] Bar-Hillel et al. `Learning distance functions using equivalence relations `_. ICML 2003 + [1]. Shental et al. `Adjustment learning and relevant component analysis `_. ECCV 2002. - .. [3] Bar-Hillel et al. `Learning a Mahalanobis metric from equivalence constraints `_. JMLR 2005 + [2]. Bar-Hillel et al. `Learning distance functions using equivalence relations `_. ICML 2003. + + [3]. Bar-Hillel et al. `Learning a Mahalanobis metric from equivalence constraints `_. JMLR 2005. .. _mmc: @@ -576,7 +583,7 @@ points, while constrains the sum of distances between dissimilar points: \qquad \qquad \text{s.t.} \qquad \sum_{(\mathbf{x}_i, \mathbf{x}_j) \in D} d^2_{\mathbf{M}}(\mathbf{x}_i, \mathbf{x}_j) \geq 1 -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -594,13 +601,14 @@ points, while constrains the sum of distances between dissimilar points: mmc = MMC() mmc.fit(pairs, y) -.. topic:: References: +.. rubric:: References + - .. [1] Xing et al. `Distance metric learning with application to clustering with - side-information `_. NIPS 2002 - .. [2] Adapted from Matlab code http://www.cs.cmu.edu/%7Eepxing/papers/Old_papers/code_Metric_online.tar.gz +.. container:: hatnote hatnote-gray + + [1]. Xing et al. `Distance metric learning with application to clustering with side-information `_. NIPS 2002. + + [2]. Adapted from Matlab code http://www.cs.cmu.edu/%7Eepxing/papers/Old_papers/code_Metric_online.tar.gz . .. _learning_on_triplets: @@ -744,7 +752,7 @@ is added to yield a sparse combination. The formulation is the following: where :math:`[\cdot]_+` is the hinge loss. -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -758,14 +766,14 @@ where :math:`[\cdot]_+` is the hinge loss. scml = SCML() scml.fit(triplets) -.. topic:: References: +.. rubric:: References + - .. [1] Y. Shi, A. Bellet and F. Sha. `Sparse Compositional Metric Learning. - `_. \ - (AAAI), 2014. +.. container:: hatnote hatnote-gray - .. [2] Adapted from original \ - `Matlab implementation.`_. + [1]. Y. Shi, A. Bellet and F. Sha. `Sparse Compositional Metric Learning. `_. (AAAI), 2014. + + [2]. Adapted from original `Matlab implementation. `_. .. _learning_on_quadruplets: @@ -937,7 +945,7 @@ by default, :math:`D_{ld}(\mathbf{\cdot, \cdot})` is the LogDet divergence: D_{ld}(\mathbf{M, M_0}) = \text{tr}(\mathbf{MM_0}) − \text{logdet} (\mathbf{M}) -.. topic:: Example Code: +.. rubric:: Example Code :: @@ -954,12 +962,13 @@ by default, :math:`D_{ld}(\mathbf{\cdot, \cdot})` is the LogDet divergence: lsml = LSML() lsml.fit(quadruplets) -.. topic:: References: +.. rubric:: References + + +.. container:: hatnote hatnote-gray - .. [1] Liu et al. - `Metric Learning from Relative Comparisons by Minimizing Squared - Residual `_. ICDM 2012 + [1]. Liu et al. `Metric Learning from Relative Comparisons by Minimizing Squared Residual `_. ICDM 2012. - .. [2] Code adapted from https://gist.github.com/kcarnold/5439917 + [2]. Code adapted from https://gist.github.com/kcarnold/5439917 . diff --git a/examples/plot_metric_learning_examples.py b/examples/plot_metric_learning_examples.py index d03242b2..4ef15497 100644 --- a/examples/plot_metric_learning_examples.py +++ b/examples/plot_metric_learning_examples.py @@ -39,9 +39,9 @@ # We will be using a synthetic dataset to illustrate the plotting, # using the function `sklearn.datasets.make_classification` from # scikit-learn. The dataset will contain: -# - 100 points in 3 classes with 2 clusters per class -# - 5 features, among which 3 are informative (correlated with the class -# labels) and two are random noise with large magnitude +# - 100 points in 3 classes with 2 clusters per class +# - 5 features, among which 3 are informative (correlated with the class +# labels) and two are random noise with large magnitude X, y = make_classification(n_samples=100, n_classes=3, n_clusters_per_class=2, n_informative=3, class_sep=4., n_features=5, diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 9064c100..7b449c8e 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -29,16 +29,17 @@ def __init__(self, preprocessor=None): @abstractmethod def score_pairs(self, pairs): """ - .. deprecated:: 0.7.0 Refer to `pair_distance` and `pair_score`. + Returns the score between pairs + (can be a similarity, or a distance/metric depending on the algorithm) + + .. deprecated:: 0.7.0 + Refer to `pair_distance` and `pair_score`. .. warning:: This method will be removed in 0.8.0. Please refer to `pair_distance` or `pair_score`. This change will occur in order to add learners that don't necessarily learn a Mahalanobis distance. - Returns the score between pairs - (can be a similarity, or a distance/metric depending on the algorithm) - Parameters ---------- pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) @@ -225,7 +226,10 @@ def get_metric(self): class MetricTransformer(metaclass=ABCMeta): - + """ + Base class for all learners that can transform data into a new space + with the metric learned. + """ @abstractmethod def transform(self, X): """Applies the metric transformation. @@ -264,14 +268,6 @@ class MahalanobisMixin(BaseMetricLearner, MetricTransformer, def score_pairs(self, pairs): r""" - .. deprecated:: 0.7.0 - This method is deprecated. Please use `pair_distance` instead. - - .. warning:: - This method will be removed in 0.8.0. Please refer to `pair_distance` - or `pair_score`. This change will occur in order to add learners - that don't necessarily learn a Mahalanobis distance. - Returns the learned Mahalanobis distance between pairs. This distance is defined as: :math:`d_M(x, x') = \\sqrt{(x-x')^T M (x-x')}` @@ -282,6 +278,14 @@ def score_pairs(self, pairs): x_e')^T (x_e- x_e')}`, with :math:`x_e = L x` (See :class:`MahalanobisMixin`). + .. deprecated:: 0.7.0 + Please use `pair_distance` instead. + + .. warning:: + This method will be removed in 0.8.0. Please refer to `pair_distance` + or `pair_score`. This change will occur in order to add learners + that don't necessarily learn a Mahalanobis distance. + Parameters ---------- pairs : array-like, shape=(n_pairs, 2, n_features) or (n_pairs, 2) @@ -632,7 +636,7 @@ def calibrate_threshold(self, pairs_valid, y_valid, strategy='accuracy', evaluation tool in clinical medicine, MH Zweig, G Campbell - Clinical chemistry, 1993 - .. [2] most of the code of this function is from scikit-learn's PR #10117 + .. [2] Most of the code of this function is from scikit-learn's PR #10117 See Also -------- @@ -745,7 +749,8 @@ def _validate_calibration_params(strategy='accuracy', min_rate=None, class _TripletsClassifierMixin(BaseMetricLearner): - """Base class for triplets learners. + """ + Base class for triplets learners. """ _tuple_size = 3 # number of points in a tuple, 3 for triplets @@ -829,7 +834,8 @@ def score(self, triplets): class _QuadrupletsClassifierMixin(BaseMetricLearner): - """Base class for quadruplets learners. + """ + Base class for quadruplets learners. """ _tuple_size = 4 # number of points in a tuple, 4 for quadruplets diff --git a/metric_learn/constraints.py b/metric_learn/constraints.py index 2d86b819..68e205f6 100644 --- a/metric_learn/constraints.py +++ b/metric_learn/constraints.py @@ -95,12 +95,14 @@ def generate_knntriplets(self, X, k_genuine, k_impostor): Parameters ---------- - X : (n x d) matrix - Input data, where each row corresponds to a single instance. - k_genuine : int - Number of neighbors of the same class to be taken into account. - k_impostor : int - Number of neighbors of different classes to be taken into account. + X : (n x d) matrix + Input data, where each row corresponds to a single instance. + + k_genuine : int + Number of neighbors of the same class to be taken into account. + + k_impostor : int + Number of neighbors of different classes to be taken into account. Returns ------- diff --git a/metric_learn/covariance.py b/metric_learn/covariance.py index 3b218e6d..2c05b28d 100644 --- a/metric_learn/covariance.py +++ b/metric_learn/covariance.py @@ -42,6 +42,10 @@ def __init__(self, preprocessor=None): def fit(self, X, y=None): """ + Calculates the covariance matrix of the input data. + + Parameters + ---------- X : data matrix, (n x d) y : unused """ diff --git a/metric_learn/itml.py b/metric_learn/itml.py index 9fa3b75e..af87f530 100644 --- a/metric_learn/itml.py +++ b/metric_learn/itml.py @@ -198,7 +198,7 @@ class ITML(_BaseITML, _PairsClassifierMixin): ---------- .. [1] Jason V. Davis, et al. `Information-theoretic Metric Learning `_. ICML 2007. + /DavisKJSD07_ICML.pdf>`_. ICML 2007. """ def fit(self, pairs, y, bounds=None, calibration_params=None): diff --git a/metric_learn/lfda.py b/metric_learn/lfda.py index bfa3275e..82ae20eb 100644 --- a/metric_learn/lfda.py +++ b/metric_learn/lfda.py @@ -65,7 +65,7 @@ class LFDA(MahalanobisMixin, TransformerMixin): >>> lfda.fit(X, Y) References - ------------------ + ---------- .. [1] Masashi Sugiyama. `Dimensionality Reduction of Multimodal Labeled Data by Local Fisher Discriminant Analysis `_. JMLR 2007. diff --git a/metric_learn/rca.py b/metric_learn/rca.py index 1da00062..a63aa1d8 100644 --- a/metric_learn/rca.py +++ b/metric_learn/rca.py @@ -58,7 +58,7 @@ class RCA(MahalanobisMixin, TransformerMixin): >>> rca.fit(X, chunks) References - ------------------ + ---------- .. [1] Noam Shental, et al. `Adjustment learning and relevant component analysis `_ . diff --git a/metric_learn/scml.py b/metric_learn/scml.py index b86c6fe1..2bdd0d57 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -377,8 +377,8 @@ class SCML(_BaseSCML, _TripletsClassifierMixin): `_. \ (AAAI), 2014. - .. [2] Adapted from original \ - `Matlab implementation.`_. + .. [2] Adapted from original `Matlab implementation. \ + `_. See Also -------- @@ -492,8 +492,8 @@ class SCML_Supervised(_BaseSCML, TransformerMixin): `_. \ (AAAI), 2014. - .. [2] Adapted from original \ - `Matlab implementation.`_. + .. [2] Adapted from original `Matlab implementation. \ + `_. See Also -------- diff --git a/setup.py b/setup.py index 255671a2..23392077 100755 --- a/setup.py +++ b/setup.py @@ -68,7 +68,8 @@ 'scikit-learn>=0.21.3', ], extras_require=dict( - docs=['sphinx', 'shinx_rtd_theme', 'numpydoc'], + docs=['sphinx', 'sphinx_rtd_theme', 'numpydoc', 'sphinx-gallery', + 'matplotlib'], demo=['matplotlib'], sdml=['skggm>=0.2.9'] ), From 72b76c86577c9cfdd4691c67007acadc0b4ef921 Mon Sep 17 00:00:00 2001 From: Nikolaos Michas <6445960+nikosmichas@users.noreply.github.com> Date: Fri, 11 Mar 2022 08:00:21 +0200 Subject: [PATCH 24/40] Update SCML docstring (#347) match actual default value of max_iter --- metric_learn/scml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index 2bdd0d57..199dfc40 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -338,7 +338,7 @@ class SCML(_BaseSCML, _TripletsClassifierMixin): gamma: float (default = 5e-3) Learning rate for the optimization algorithm. - max_iter : int (default = 100000) + max_iter : int (default = 10000) Number of iterations for the algorithm. output_iter : int (default = 5000) From b8530b2c92510316d9d5e69517aabe8511cb1ba8 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Mon, 20 Jun 2022 09:52:47 -0400 Subject: [PATCH 25/40] Support for SLEP010 This requires setting a public `n_features_in_` attribute as part of the fit() logic. For details, see: https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep010/proposal.html --- .gitignore | 3 ++- metric_learn/base_metric.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 16917890..66eb3551 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ htmlcov/ .pytest_cache/ doc/auto_examples/* doc/generated/* -venv/ \ No newline at end of file +venv/ +.vscode/ diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index 7b449c8e..d0ba1ef9 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -166,12 +166,16 @@ def _prepare_inputs(self, X, y=None, type_of_inputs='classic', self._check_preprocessor() check_is_fitted(self, ['preprocessor_']) - return check_input(X, y, + outs = check_input(X, y, type_of_inputs=type_of_inputs, preprocessor=self.preprocessor_, estimator=self, tuple_size=getattr(self, '_tuple_size', None), **kwargs) + # Conform to SLEP010 + if not hasattr(self, 'n_features_in_'): + self.n_features_in_ = (outs if y is None else outs[0]).shape[1] + return outs @abstractmethod def get_metric(self): From d78c720b87723114262e839717fafee16e7eba42 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Mon, 20 Jun 2022 09:55:09 -0400 Subject: [PATCH 26/40] Update CI to test on Python 3.10 --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fbe91dea..aaec0736 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,13 +35,13 @@ jobs: pytest test --cov bash <(curl -s https://codecov.io/bash) - # Run normal testing with the latests versions of all dependencies + # Run normal testing with the latest versions of all dependencies build: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 - name: Set up Python From 85204180e7ae5f4ef709830184da3ccf6cfa14d2 Mon Sep 17 00:00:00 2001 From: Nikolaos Michas <6445960+nikosmichas@users.noreply.github.com> Date: Tue, 21 Jun 2022 02:53:54 +0300 Subject: [PATCH 27/40] SCML: Raise ValueError if n_features larger than n_triplets (#350) --- metric_learn/scml.py | 6 ++++++ test/test_triplets_classifiers.py | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index 199dfc40..db2fdf64 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -240,6 +240,12 @@ def _generate_bases_dist_diff(self, triplets, X): raise ValueError("n_basis should be an integer, instead it is of type %s" % type(self.n_basis)) + if n_features > n_triplets: + raise ValueError( + "Number of features (%s) is greater than the number of triplets(%s).\n" + "Consider using dimensionality reduction or using another basis " + "generation scheme." % (n_features, n_triplets)) + basis = np.zeros((n_basis, n_features)) # get all positive and negative pairs with lowest index first diff --git a/test/test_triplets_classifiers.py b/test/test_triplets_classifiers.py index f2d5c015..515a0a33 100644 --- a/test/test_triplets_classifiers.py +++ b/test/test_triplets_classifiers.py @@ -2,7 +2,12 @@ from sklearn.exceptions import NotFittedError from sklearn.model_selection import train_test_split -from test.test_utils import triplets_learners, ids_triplets_learners +from metric_learn import SCML +from test.test_utils import ( + triplets_learners, + ids_triplets_learners, + build_triplets +) from metric_learn.sklearn_shims import set_random_state from sklearn import clone import numpy as np @@ -107,3 +112,16 @@ def test_accuracy_toy_example(estimator, build_dataset): # we force the transformation to be identity so that we control what it does estimator.components_ = np.eye(X.shape[1]) assert estimator.score(triplets_test) == 0.25 + + +def test_raise_big_number_of_features(): + triplets, _, _, X = build_triplets(with_preprocessor=False) + triplets = triplets[:3, :, :] + estimator = SCML(n_basis=320) + set_random_state(estimator) + with pytest.raises(ValueError) as exc_info: + estimator.fit(triplets) + assert exc_info.value.args[0] == \ + "Number of features (4) is greater than the number of triplets(3)." \ + "\nConsider using dimensionality reduction or using another basis " \ + "generation scheme." From 17216a7b0db43b310a2f041a97f631b9ce425789 Mon Sep 17 00:00:00 2001 From: Maximiliano Vargas <43217761+mvargas33@users.noreply.github.com> Date: Mon, 20 Jun 2022 20:09:16 -0400 Subject: [PATCH 28/40] Rename variables, proposed by issue #257 (#324) * Rename number_constrains to n_constraints * Renamed num_chunks to n_chunks * LMNN k parameter renamed to n_neighbors * Replaced all 'convergence_threshold' with 'tol' * Fix tests * Fixed more test regarding rename of variable * Warnings for n_constrains * Add all warnings regarding n_constrains * Deprecation warnings for n_chunks * Add deprecation warn to n_neighbors * Add convergence_threshold warnings --- bench/benchmarks/iris.py | 10 ++-- doc/supervised.rst | 8 +-- doc/weakly_supervised.rst | 4 +- examples/plot_metric_learning_examples.py | 4 +- examples/plot_sandwich.py | 6 +- metric_learn/constraints.py | 60 +++++++++++++------- metric_learn/itml.py | 62 +++++++++++++++------ metric_learn/lmnn.py | 28 +++++++--- metric_learn/lsml.py | 33 +++++++---- metric_learn/mmc.py | 68 ++++++++++++++++------- metric_learn/rca.py | 32 +++++++---- metric_learn/sdml.py | 29 +++++++--- test/metric_learn_test.py | 28 +++++----- test/test_base_metric.py | 33 +++++------ test/test_components_metric_conversion.py | 10 ++-- test/test_constraints.py | 38 ++++++------- test/test_fit_transform.py | 24 ++++---- test/test_mahalanobis_mixin.py | 2 +- test/test_sklearn_compat.py | 6 +- test/test_utils.py | 6 +- 20 files changed, 305 insertions(+), 186 deletions(-) diff --git a/bench/benchmarks/iris.py b/bench/benchmarks/iris.py index 5973f7b8..05035085 100644 --- a/bench/benchmarks/iris.py +++ b/bench/benchmarks/iris.py @@ -5,15 +5,15 @@ CLASSES = { 'Covariance': metric_learn.Covariance(), - 'ITML_Supervised': metric_learn.ITML_Supervised(num_constraints=200), + 'ITML_Supervised': metric_learn.ITML_Supervised(n_constraints=200), 'LFDA': metric_learn.LFDA(k=2, dim=2), - 'LMNN': metric_learn.LMNN(k=5, learn_rate=1e-6, verbose=False), - 'LSML_Supervised': metric_learn.LSML_Supervised(num_constraints=200), + 'LMNN': metric_learn.LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False), + 'LSML_Supervised': metric_learn.LSML_Supervised(n_constraints=200), 'MLKR': metric_learn.MLKR(), 'NCA': metric_learn.NCA(max_iter=700, n_components=2), - 'RCA_Supervised': metric_learn.RCA_Supervised(dim=2, num_chunks=30, + 'RCA_Supervised': metric_learn.RCA_Supervised(dim=2, n_chunks=30, chunk_size=2), - 'SDML_Supervised': metric_learn.SDML_Supervised(num_constraints=1500) + 'SDML_Supervised': metric_learn.SDML_Supervised(n_constraints=1500) } diff --git a/doc/supervised.rst b/doc/supervised.rst index 09077dc2..a847a33c 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -164,7 +164,7 @@ indicates :math:`\mathbf{x}_{i}, \mathbf{x}_{j}` belong to different classes, X = iris_data['data'] Y = iris_data['target'] - lmnn = LMNN(k=5, learn_rate=1e-6) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6) lmnn.fit(X, Y, verbose=False) .. rubric:: References @@ -407,8 +407,8 @@ are similar (+1) or dissimilar (-1)), are sampled with the function (of label +1), this method will look at all the samples from the same label and sample randomly a pair among them. To sample negative pairs (of label -1), this method will look at all the samples from a different class and sample randomly -a pair among them. The method will try to build `num_constraints` positive -pairs and `num_constraints` negative pairs, but sometimes it cannot find enough +a pair among them. The method will try to build `n_constraints` positive +pairs and `n_constraints` negative pairs, but sometimes it cannot find enough of one of those, so forcing `same_length=True` will return both times the minimum of the two lenghts. @@ -430,5 +430,5 @@ last points should be less similar than the two first points). X = iris_data['data'] Y = iris_data['target'] - mmc = MMC_Supervised(num_constraints=200) + mmc = MMC_Supervised(n_constraints=200) mmc.fit(X, Y) diff --git a/doc/weakly_supervised.rst b/doc/weakly_supervised.rst index 341d959a..76f7c14e 100644 --- a/doc/weakly_supervised.rst +++ b/doc/weakly_supervised.rst @@ -137,7 +137,7 @@ are respected. >>> from metric_learn import MMC >>> mmc = MMC(random_state=42) >>> mmc.fit(tuples, y) -MMC(A0='deprecated', convergence_threshold=0.001, diagonal=False, +MMC(A0='deprecated', tol=0.001, diagonal=False, diagonal_c=1.0, init='auto', max_iter=100, max_proj=10000, preprocessor=None, random_state=42, verbose=False) @@ -263,7 +263,7 @@ tuples). >>> y_pairs = np.array([1, -1]) >>> mmc = MMC(random_state=42) >>> mmc.fit(pairs, y_pairs) -MMC(convergence_threshold=0.001, diagonal=False, +MMC(tol=0.001, diagonal=False, diagonal_c=1.0, init='auto', max_iter=100, max_proj=10000, preprocessor=None, random_state=42, verbose=False) diff --git a/examples/plot_metric_learning_examples.py b/examples/plot_metric_learning_examples.py index 4ef15497..32759636 100644 --- a/examples/plot_metric_learning_examples.py +++ b/examples/plot_metric_learning_examples.py @@ -143,7 +143,7 @@ def plot_tsne(X, y, colormap=plt.cm.Paired): # # setting up LMNN -lmnn = metric_learn.LMNN(k=5, learn_rate=1e-6) +lmnn = metric_learn.LMNN(n_neighbors=5, learn_rate=1e-6) # fit the data! lmnn.fit(X, y) @@ -314,7 +314,7 @@ def plot_tsne(X, y, colormap=plt.cm.Paired): # - See more in the documentation of the class :py:class:`RCA # ` -rca = metric_learn.RCA_Supervised(num_chunks=30, chunk_size=2) +rca = metric_learn.RCA_Supervised(n_chunks=30, chunk_size=2) X_rca = rca.fit_transform(X, y) plot_tsne(X_rca, y) diff --git a/examples/plot_sandwich.py b/examples/plot_sandwich.py index 07ab78e9..740852be 100644 --- a/examples/plot_sandwich.py +++ b/examples/plot_sandwich.py @@ -35,9 +35,9 @@ def sandwich_demo(): mls = [ LMNN(), - ITML_Supervised(num_constraints=200), - SDML_Supervised(num_constraints=200, balance_param=0.001), - LSML_Supervised(num_constraints=200), + ITML_Supervised(n_constraints=200), + SDML_Supervised(n_constraints=200, balance_param=0.001), + LSML_Supervised(n_constraints=200), ] for ax_num, ml in enumerate(mls, start=3): diff --git a/metric_learn/constraints.py b/metric_learn/constraints.py index 68e205f6..4993e9ef 100644 --- a/metric_learn/constraints.py +++ b/metric_learn/constraints.py @@ -7,6 +7,7 @@ from sklearn.utils import check_random_state from sklearn.neighbors import NearestNeighbors + __all__ = ['Constraints'] @@ -31,21 +32,21 @@ def __init__(self, partial_labels): partial_labels = np.asanyarray(partial_labels, dtype=int) self.partial_labels = partial_labels - def positive_negative_pairs(self, num_constraints, same_length=False, - random_state=None): + def positive_negative_pairs(self, n_constraints, same_length=False, + random_state=None, num_constraints='deprecated'): """ Generates positive pairs and negative pairs from labeled data. - Positive pairs are formed by randomly drawing ``num_constraints`` pairs of + Positive pairs are formed by randomly drawing ``n_constraints`` pairs of points with the same label. Negative pairs are formed by randomly drawing - ``num_constraints`` pairs of points with different label. + ``n_constraints`` pairs of points with different label. In the case where it is not possible to generate enough positive or negative pairs, a smaller number of pairs will be returned with a warning. Parameters ---------- - num_constraints : int + n_constraints : int Number of positive and negative constraints to generate. same_length : bool, optional (default=False) @@ -55,6 +56,8 @@ def positive_negative_pairs(self, num_constraints, same_length=False, random_state : int or numpy.RandomState or None, optional (default=None) A pseudo random number generator object or a seed for it if int. + num_constraints : Renamed to n_constraints. Will be deprecated in 0.7.0 + Returns ------- a : array-like, shape=(n_constraints,) @@ -69,10 +72,18 @@ def positive_negative_pairs(self, num_constraints, same_length=False, d : array-like, shape=(n_constraints,) 1D array of indicators for the right elements of negative pairs. """ + if num_constraints != 'deprecated': + warnings.warn('"num_constraints" parameter has been renamed to' + ' "n_constraints". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + self.n_constraints = num_constraints + else: + self.n_constraints = n_constraints random_state = check_random_state(random_state) - a, b = self._pairs(num_constraints, same_label=True, + a, b = self._pairs(n_constraints, same_label=True, random_state=random_state) - c, d = self._pairs(num_constraints, same_label=False, + c, d = self._pairs(n_constraints, same_label=False, random_state=random_state) if same_length and len(a) != len(c): n = min(len(a), len(c)) @@ -190,15 +201,15 @@ def generate_knntriplets(self, X, k_genuine, k_impostor): return triplets - def _pairs(self, num_constraints, same_label=True, max_iter=10, + def _pairs(self, n_constraints, same_label=True, max_iter=10, random_state=np.random): known_label_idx, = np.where(self.partial_labels >= 0) known_labels = self.partial_labels[known_label_idx] num_labels = len(known_labels) ab = set() it = 0 - while it < max_iter and len(ab) < num_constraints: - nc = num_constraints - len(ab) + while it < max_iter and len(ab) < n_constraints: + nc = n_constraints - len(ab) for aidx in random_state.randint(num_labels, size=nc): if same_label: mask = known_labels[aidx] == known_labels @@ -209,25 +220,26 @@ def _pairs(self, num_constraints, same_label=True, max_iter=10, if len(b_choices) > 0: ab.add((aidx, random_state.choice(b_choices))) it += 1 - if len(ab) < num_constraints: + if len(ab) < n_constraints: warnings.warn("Only generated %d %s constraints (requested %d)" % ( - len(ab), 'positive' if same_label else 'negative', num_constraints)) - ab = np.array(list(ab)[:num_constraints], dtype=int) + len(ab), 'positive' if same_label else 'negative', n_constraints)) + ab = np.array(list(ab)[:n_constraints], dtype=int) return known_label_idx[ab.T] - def chunks(self, num_chunks=100, chunk_size=2, random_state=None): + def chunks(self, n_chunks=100, chunk_size=2, random_state=None, + num_chunks='deprecated'): """ Generates chunks from labeled data. - Each of ``num_chunks`` chunks is composed of ``chunk_size`` points from + Each of ``n_chunks`` chunks is composed of ``chunk_size`` points from the same class drawn at random. Each point can belong to at most 1 chunk. - In the case where there is not enough points to generate ``num_chunks`` + In the case where there is not enough points to generate ``n_chunks`` chunks of size ``chunk_size``, a ValueError will be raised. Parameters ---------- - num_chunks : int, optional (default=100) + n_chunks : int, optional (default=100) Number of chunks to generate. chunk_size : int, optional (default=2) @@ -236,12 +248,20 @@ def chunks(self, num_chunks=100, chunk_size=2, random_state=None): random_state : int or numpy.RandomState or None, optional (default=None) A pseudo random number generator object or a seed for it if int. + num_chunks : Renamed to n_chunks. Will be deprecated in 0.7.0 + Returns ------- chunks : array-like, shape=(n_samples,) 1D array of chunk indicators, where -1 indicates that the point does not belong to any chunk. """ + if num_chunks != 'deprecated': + warnings.warn('"num_chunks" parameter has been renamed to' + ' "n_chunks". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + n_chunks = num_chunks random_state = check_random_state(random_state) chunks = -np.ones_like(self.partial_labels, dtype=int) uniq, lookup = np.unique(self.partial_labels, return_inverse=True) @@ -249,13 +269,13 @@ def chunks(self, num_chunks=100, chunk_size=2, random_state=None): all_inds = [set(np.where(lookup == c)[0]) for c in range(len(uniq)) if c not in unknown_uniq] max_chunks = int(np.sum([len(s) // chunk_size for s in all_inds])) - if max_chunks < num_chunks: + if max_chunks < n_chunks: raise ValueError(('Not enough possible chunks of %d elements in each' ' class to form expected %d chunks - maximum number' ' of chunks is %d' - ) % (chunk_size, num_chunks, max_chunks)) + ) % (chunk_size, n_chunks, max_chunks)) idx = 0 - while idx < num_chunks and all_inds: + while idx < n_chunks and all_inds: if len(all_inds) == 1: c = 0 else: diff --git a/metric_learn/itml.py b/metric_learn/itml.py index af87f530..9537eec2 100644 --- a/metric_learn/itml.py +++ b/metric_learn/itml.py @@ -9,6 +9,7 @@ from .base_metric import _PairsClassifierMixin, MahalanobisMixin from .constraints import Constraints, wrap_pairs from ._util import components_from_metric, _initialize_metric_mahalanobis +import warnings class _BaseITML(MahalanobisMixin): @@ -16,12 +17,20 @@ class _BaseITML(MahalanobisMixin): _tuple_size = 2 # constraints are pairs - def __init__(self, gamma=1., max_iter=1000, convergence_threshold=1e-3, + def __init__(self, gamma=1., max_iter=1000, tol=1e-3, prior='identity', verbose=False, - preprocessor=None, random_state=None): + preprocessor=None, random_state=None, + convergence_threshold='deprecated'): + if convergence_threshold != 'deprecated': + warnings.warn('"convergence_threshold" parameter has been ' + ' renamed to "tol". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + tol = convergence_threshold + self.convergence_threshold = 'deprecated' # Avoid errors self.gamma = gamma self.max_iter = max_iter - self.convergence_threshold = convergence_threshold + self.tol = tol self.prior = prior self.verbose = verbose self.random_state = random_state @@ -86,7 +95,7 @@ def _fit(self, pairs, y, bounds=None): conv = np.inf break conv = np.abs(lambdaold - _lambda).sum() / normsum - if conv < self.convergence_threshold: + if conv < self.tol: break lambdaold = _lambda.copy() if self.verbose: @@ -122,7 +131,7 @@ class ITML(_BaseITML, _PairsClassifierMixin): max_iter : int, optional (default=1000) Maximum number of iteration of the optimization procedure. - convergence_threshold : float, optional (default=1e-3) + tol : float, optional (default=1e-3) Convergence tolerance. prior : string or numpy array, optional (default='identity') @@ -158,6 +167,8 @@ class ITML(_BaseITML, _PairsClassifierMixin): A pseudo random number generator object or a seed for it if int. If ``prior='random'``, ``random_state`` is used to set the prior. + convergence_threshold : Renamed to tol. Will be deprecated in 0.7.0 + Attributes ---------- bounds_ : `numpy.ndarray`, shape=(2,) @@ -260,10 +271,10 @@ class ITML_Supervised(_BaseITML, TransformerMixin): max_iter : int, optional (default=1000) Maximum number of iterations of the optimization procedure. - convergence_threshold : float, optional (default=1e-3) + tol : float, optional (default=1e-3) Tolerance of the optimization procedure. - num_constraints : int, optional (default=None) + n_constraints : int, optional (default=None) Number of constraints to generate. If None, default to `20 * num_classes**2`. @@ -302,6 +313,9 @@ class ITML_Supervised(_BaseITML, TransformerMixin): case, `random_state` is also used to randomly sample constraints from labels. + num_constraints : Renamed to n_constraints. Will be deprecated in 0.7.0 + + convergence_threshold : Renamed to tol. Will be deprecated in 0.7.0 Attributes ---------- @@ -328,7 +342,7 @@ class ITML_Supervised(_BaseITML, TransformerMixin): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> itml = ITML_Supervised(num_constraints=200) + >>> itml = ITML_Supervised(n_constraints=200) >>> itml.fit(X, Y) See Also @@ -338,14 +352,26 @@ class ITML_Supervised(_BaseITML, TransformerMixin): that describes the supervised version of weakly supervised estimators. """ - def __init__(self, gamma=1.0, max_iter=1000, convergence_threshold=1e-3, - num_constraints=None, prior='identity', - verbose=False, preprocessor=None, random_state=None): + def __init__(self, gamma=1.0, max_iter=1000, tol=1e-3, + n_constraints=None, prior='identity', + verbose=False, preprocessor=None, random_state=None, + num_constraints='deprecated', + convergence_threshold='deprecated'): _BaseITML.__init__(self, gamma=gamma, max_iter=max_iter, - convergence_threshold=convergence_threshold, + tol=tol, prior=prior, verbose=verbose, - preprocessor=preprocessor, random_state=random_state) - self.num_constraints = num_constraints + preprocessor=preprocessor, + random_state=random_state, + convergence_threshold=convergence_threshold) + if num_constraints != 'deprecated': + warnings.warn('"num_constraints" parameter has been renamed to' + ' "n_constraints". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + n_constraints = num_constraints + self.n_constraints = n_constraints + # Avoid test get_params from failing (all params passed sholud be set) + self.num_constraints = 'deprecated' def fit(self, X, y, bounds=None): """Create constraints from labels and learn the ITML model. @@ -369,13 +395,13 @@ def fit(self, X, y, bounds=None): points in the training data `X`. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - num_constraints = self.num_constraints - if num_constraints is None: + n_constraints = self.n_constraints + if n_constraints is None: num_classes = len(np.unique(y)) - num_constraints = 20 * num_classes**2 + n_constraints = 20 * num_classes**2 c = Constraints(y) - pos_neg = c.positive_negative_pairs(num_constraints, + pos_neg = c.positive_negative_pairs(n_constraints, random_state=self.random_state) pairs, y = wrap_pairs(X, pos_neg) return _BaseITML._fit(self, pairs, y, bounds=bounds) diff --git a/metric_learn/lmnn.py b/metric_learn/lmnn.py index 8bdc4bf0..47bb065f 100644 --- a/metric_learn/lmnn.py +++ b/metric_learn/lmnn.py @@ -5,6 +5,7 @@ from collections import Counter from sklearn.metrics import euclidean_distances from sklearn.base import TransformerMixin +import warnings from ._util import _initialize_components, _check_n_components from .base_metric import MahalanobisMixin @@ -63,7 +64,7 @@ class LMNN(MahalanobisMixin, TransformerMixin): :meth:`fit` and n_features_a must be less than or equal to that. If ``n_components`` is not None, n_features_a must match it. - k : int, optional (default=3) + n_neighbors : int, optional (default=3) Number of neighbors to consider, not including self-edges. min_iter : int, optional (default=50) @@ -99,6 +100,8 @@ class LMNN(MahalanobisMixin, TransformerMixin): transformation. If ``init='pca'``, ``random_state`` is passed as an argument to PCA when initializing the transformation. + k : Renamed to n_neighbors. Will be deprecated in 0.7.0 + Attributes ---------- n_iter_ : `int` @@ -116,7 +119,7 @@ class LMNN(MahalanobisMixin, TransformerMixin): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> lmnn = LMNN(k=5, learn_rate=1e-6) + >>> lmnn = LMNN(n_neighbors=5, learn_rate=1e-6) >>> lmnn.fit(X, Y, verbose=False) References @@ -128,12 +131,19 @@ class LMNN(MahalanobisMixin, TransformerMixin): 2005. """ - def __init__(self, init='auto', k=3, min_iter=50, max_iter=1000, + def __init__(self, init='auto', n_neighbors=3, min_iter=50, max_iter=1000, learn_rate=1e-7, regularization=0.5, convergence_tol=0.001, verbose=False, preprocessor=None, - n_components=None, random_state=None): + n_components=None, random_state=None, k='deprecated'): self.init = init - self.k = k + if k != 'deprecated': + warnings.warn('"num_chunks" parameter has been renamed to' + ' "n_chunks". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + n_neighbors = k + self.k = 'deprecated' # To avoid no_attribute error + self.n_neighbors = n_neighbors self.min_iter = min_iter self.max_iter = max_iter self.learn_rate = learn_rate @@ -145,7 +155,7 @@ def __init__(self, init='auto', k=3, min_iter=50, max_iter=1000, super(LMNN, self).__init__(preprocessor) def fit(self, X, y): - k = self.k + k = self.n_neighbors reg = self.regularization learn_rate = self.learn_rate @@ -162,7 +172,7 @@ def fit(self, X, y): self.verbose, random_state=self.random_state) required_k = np.bincount(label_inds).min() - if self.k > required_k: + if self.n_neighbors > required_k: raise ValueError('not enough class labels for specified k' ' (smallest class has %d)' % required_k) @@ -275,12 +285,12 @@ def _loss_grad(self, X, L, dfG, k, reg, target_neighbors, label_inds): return 2 * G, objective, total_active def _select_targets(self, X, label_inds): - target_neighbors = np.empty((X.shape[0], self.k), dtype=int) + target_neighbors = np.empty((X.shape[0], self.n_neighbors), dtype=int) for label in self.labels_: inds, = np.nonzero(label_inds == label) dd = euclidean_distances(X[inds], squared=True) np.fill_diagonal(dd, np.inf) - nn = np.argsort(dd)[..., :self.k] + nn = np.argsort(dd)[..., :self.n_neighbors] target_neighbors[inds] = inds[nn] return target_neighbors diff --git a/metric_learn/lsml.py b/metric_learn/lsml.py index 28f65ce7..af7fa95b 100644 --- a/metric_learn/lsml.py +++ b/metric_learn/lsml.py @@ -9,6 +9,7 @@ from .base_metric import _QuadrupletsClassifierMixin, MahalanobisMixin from .constraints import Constraints from ._util import components_from_metric, _initialize_metric_mahalanobis +import warnings class _BaseLSML(MahalanobisMixin): @@ -261,11 +262,11 @@ class LSML_Supervised(_BaseLSML, TransformerMixin): (n_features, n_features), that will be used as such to set the prior. - num_constraints: int, optional (default=None) + n_constraints: int, optional (default=None) Number of constraints to generate. If None, default to `20 * num_classes**2`. - weights : (num_constraints,) array of floats, optional (default=None) + weights : (n_constraints,) array of floats, optional (default=None) Relative weight given to each constraint. If None, defaults to uniform weights. @@ -282,6 +283,8 @@ class LSML_Supervised(_BaseLSML, TransformerMixin): prior. In any case, `random_state` is also used to randomly sample constraints from labels. + num_constraints : Renamed to n_constraints. Will be deprecated in 0.7.0 + Examples -------- >>> from metric_learn import LSML_Supervised @@ -289,7 +292,7 @@ class LSML_Supervised(_BaseLSML, TransformerMixin): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> lsml = LSML_Supervised(num_constraints=200) + >>> lsml = LSML_Supervised(n_constraints=200) >>> lsml.fit(X, Y) Attributes @@ -303,12 +306,22 @@ class LSML_Supervised(_BaseLSML, TransformerMixin): """ def __init__(self, tol=1e-3, max_iter=1000, prior='identity', - num_constraints=None, weights=None, - verbose=False, preprocessor=None, random_state=None): + n_constraints=None, weights=None, + verbose=False, preprocessor=None, random_state=None, + num_constraints='deprecated'): _BaseLSML.__init__(self, tol=tol, max_iter=max_iter, prior=prior, verbose=verbose, preprocessor=preprocessor, random_state=random_state) - self.num_constraints = num_constraints + if num_constraints != 'deprecated': + warnings.warn('"num_constraints" parameter has been renamed to' + ' "n_constraints". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + self.n_constraints = num_constraints + else: + self.n_constraints = n_constraints + # Avoid test get_params from failing (all params passed sholud be set) + self.num_constraints = 'deprecated' self.weights = weights def fit(self, X, y): @@ -323,13 +336,13 @@ def fit(self, X, y): Data labels. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - num_constraints = self.num_constraints - if num_constraints is None: + n_constraints = self.n_constraints + if n_constraints is None: num_classes = len(np.unique(y)) - num_constraints = 20 * num_classes**2 + n_constraints = 20 * num_classes**2 c = Constraints(y) - pos_neg = c.positive_negative_pairs(num_constraints, same_length=True, + pos_neg = c.positive_negative_pairs(n_constraints, same_length=True, random_state=self.random_state) return _BaseLSML._fit(self, X[np.column_stack(pos_neg)], weights=self.weights) diff --git a/metric_learn/mmc.py b/metric_learn/mmc.py index 1ff30b1e..5cf166fd 100644 --- a/metric_learn/mmc.py +++ b/metric_learn/mmc.py @@ -6,19 +6,28 @@ from .base_metric import _PairsClassifierMixin, MahalanobisMixin from .constraints import Constraints, wrap_pairs from ._util import components_from_metric, _initialize_metric_mahalanobis +import warnings class _BaseMMC(MahalanobisMixin): _tuple_size = 2 # constraints are pairs - def __init__(self, max_iter=100, max_proj=10000, convergence_threshold=1e-3, + def __init__(self, max_iter=100, max_proj=10000, tol=1e-3, init='identity', diagonal=False, diagonal_c=1.0, verbose=False, preprocessor=None, - random_state=None): + random_state=None, + convergence_threshold='deprecated'): + if convergence_threshold != 'deprecated': + warnings.warn('"convergence_threshold" parameter has been ' + ' renamed to "tol". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + tol = convergence_threshold + self.convergence_threshold = 'deprecated' # Avoid errors self.max_iter = max_iter self.max_proj = max_proj - self.convergence_threshold = convergence_threshold + self.tol = tol self.init = init self.diagonal = diagonal self.diagonal_c = diagonal_c @@ -145,13 +154,13 @@ def _fit_full(self, pairs, y): A[:] = A_old + alpha * M delta = np.linalg.norm(alpha * M) / np.linalg.norm(A_old) - if delta < self.convergence_threshold: + if delta < self.tol: break if self.verbose: print('mmc iter: %d, conv = %f, projections = %d' % (cycle, delta, it + 1)) - if delta > self.convergence_threshold: + if delta > self.tol: self.converged_ = False if self.verbose: print('mmc did not converge, conv = %f' % (delta,)) @@ -185,7 +194,7 @@ def _fit_diag(self, pairs, y): reduction = 2.0 w = np.diag(self.A_).copy() - while error > self.convergence_threshold and it < self.max_iter: + while error > self.tol and it < self.max_iter: fD0, fD_1st_d, fD_2nd_d = self._D_constraint(neg_pairs, w) obj_initial = np.dot(s_sum, w) + self.diagonal_c * fD0 @@ -332,7 +341,7 @@ class MMC(_BaseMMC, _PairsClassifierMixin): max_proj : int, optional (default=10000) Maximum number of projection steps. - convergence_threshold : float, optional (default=1e-3) + tol : float, optional (default=1e-3) Convergence threshold for the optimization procedure. init : string or numpy array, optional (default='identity') @@ -377,6 +386,8 @@ class MMC(_BaseMMC, _PairsClassifierMixin): ``init='random'``, ``random_state`` is used to initialize the random transformation. + convergence_threshold : Renamed to tol. Will be deprecated in 0.7.0 + Attributes ---------- n_iter_ : `int` @@ -469,10 +480,10 @@ class MMC_Supervised(_BaseMMC, TransformerMixin): max_proj : int, optional (default=10000) Maximum number of projection steps. - convergence_threshold : float, optional (default=1e-3) + tol : float, optional (default=1e-3) Convergence threshold for the optimization procedure. - num_constraints: int, optional (default=None) + n_constraints: int, optional (default=None) Number of constraints to generate. If None, default to `20 * num_classes**2`. @@ -518,6 +529,10 @@ class MMC_Supervised(_BaseMMC, TransformerMixin): Mahalanobis matrix. In any case, `random_state` is also used to randomly sample constraints from labels. + num_constraints : Renamed to n_constraints. Will be deprecated in 0.7.0 + + convergence_threshold : Renamed to tol. Will be deprecated in 0.7.0 + Examples -------- >>> from metric_learn import MMC_Supervised @@ -525,7 +540,7 @@ class MMC_Supervised(_BaseMMC, TransformerMixin): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> mmc = MMC_Supervised(num_constraints=200) + >>> mmc = MMC_Supervised(n_constraints=200) >>> mmc.fit(X, Y) Attributes @@ -538,16 +553,29 @@ class MMC_Supervised(_BaseMMC, TransformerMixin): metric (See function `components_from_metric`.) """ - def __init__(self, max_iter=100, max_proj=10000, convergence_threshold=1e-6, - num_constraints=None, init='identity', + def __init__(self, max_iter=100, max_proj=10000, tol=1e-6, + n_constraints=None, init='identity', diagonal=False, diagonal_c=1.0, verbose=False, - preprocessor=None, random_state=None): + preprocessor=None, random_state=None, + num_constraints='deprecated', + convergence_threshold='deprecated'): _BaseMMC.__init__(self, max_iter=max_iter, max_proj=max_proj, - convergence_threshold=convergence_threshold, + tol=tol, init=init, diagonal=diagonal, diagonal_c=diagonal_c, verbose=verbose, - preprocessor=preprocessor, random_state=random_state) - self.num_constraints = num_constraints + preprocessor=preprocessor, + random_state=random_state, + convergence_threshold=convergence_threshold) + if num_constraints != 'deprecated': + warnings.warn('"num_constraints" parameter has been renamed to' + ' "n_constraints". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + self.n_constraints = num_constraints + else: + self.n_constraints = n_constraints + # Avoid test get_params from failing (all params passed sholud be set) + self.num_constraints = 'deprecated' def fit(self, X, y): """Create constraints from labels and learn the MMC model. @@ -561,13 +589,13 @@ def fit(self, X, y): Data labels. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - num_constraints = self.num_constraints - if num_constraints is None: + n_constraints = self.n_constraints + if n_constraints is None: num_classes = len(np.unique(y)) - num_constraints = 20 * num_classes**2 + n_constraints = 20 * num_classes**2 c = Constraints(y) - pos_neg = c.positive_negative_pairs(num_constraints, + pos_neg = c.positive_negative_pairs(n_constraints, random_state=self.random_state) pairs, y = wrap_pairs(X, pos_neg) return _BaseMMC._fit(self, pairs, y) diff --git a/metric_learn/rca.py b/metric_learn/rca.py index a63aa1d8..253b9c92 100644 --- a/metric_learn/rca.py +++ b/metric_learn/rca.py @@ -13,13 +13,13 @@ # mean center each chunklet separately def _chunk_mean_centering(data, chunks): - num_chunks = chunks.max() + 1 + n_chunks = chunks.max() + 1 chunk_mask = chunks != -1 # We need to ensure the data is float so that we can substract the # mean on it chunk_data = data[chunk_mask].astype(float, copy=False) chunk_labels = chunks[chunk_mask] - for c in range(num_chunks): + for c in range(n_chunks): mask = chunk_labels == c chunk_data[mask] -= chunk_data[mask].mean(axis=0) @@ -135,14 +135,14 @@ class RCA_Supervised(RCA): `RCA_Supervised` creates chunks of similar points by first sampling a class, taking `chunk_size` elements in it, and repeating the process - `num_chunks` times. + `n_chunks` times. Parameters ---------- n_components : int or None, optional (default=None) Dimensionality of reduced space (if None, defaults to dimension of X). - num_chunks: int, optional (default=100) + n_chunks: int, optional (default=100) Number of chunks to generate. chunk_size: int, optional (default=2) @@ -156,6 +156,8 @@ class RCA_Supervised(RCA): A pseudo random number generator object or a seed for it if int. It is used to randomly sample constraints from labels. + num_chunks : Renamed to n_chunks. Will be deprecated in 0.7.0 + Examples -------- >>> from metric_learn import RCA_Supervised @@ -163,7 +165,7 @@ class RCA_Supervised(RCA): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> rca = RCA_Supervised(num_chunks=30, chunk_size=2) + >>> rca = RCA_Supervised(n_chunks=30, chunk_size=2) >>> rca.fit(X, Y) Attributes @@ -172,17 +174,25 @@ class RCA_Supervised(RCA): The learned linear transformation ``L``. """ - def __init__(self, n_components=None, num_chunks=100, chunk_size=2, - preprocessor=None, random_state=None): + def __init__(self, n_components=None, n_chunks=100, chunk_size=2, + preprocessor=None, random_state=None, + num_chunks='deprecated'): """Initialize the supervised version of `RCA`.""" RCA.__init__(self, n_components=n_components, preprocessor=preprocessor) - self.num_chunks = num_chunks + if num_chunks != 'deprecated': + warnings.warn('"num_chunks" parameter has been renamed to' + ' "n_chunks". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + n_chunks = num_chunks + self.num_chunks = 'deprecated' # To avoid no_attribute error + self.n_chunks = n_chunks self.chunk_size = chunk_size self.random_state = random_state def fit(self, X, y): """Create constraints from labels and learn the RCA model. - Needs num_constraints specified in constructor. + Needs n_constraints specified in constructor. (Not true?) Parameters ---------- @@ -192,11 +202,11 @@ def fit(self, X, y): y : (n) data labels """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - chunks = Constraints(y).chunks(num_chunks=self.num_chunks, + chunks = Constraints(y).chunks(n_chunks=self.n_chunks, chunk_size=self.chunk_size, random_state=self.random_state) - if self.num_chunks * (self.chunk_size - 1) < X.shape[1]: + if self.n_chunks * (self.chunk_size - 1) < X.shape[1]: warnings.warn('Due to the parameters of RCA_Supervised, ' 'the inner covariance matrix is not invertible, ' 'so the transformation matrix will contain Nan values. ' diff --git a/metric_learn/sdml.py b/metric_learn/sdml.py index a0736ffa..93f3f441 100644 --- a/metric_learn/sdml.py +++ b/metric_learn/sdml.py @@ -177,7 +177,7 @@ class SDML(_BaseSDML, _PairsClassifierMixin): >>> iris_data = load_iris() >>> X = iris_data['data'] >>> Y = iris_data['target'] - >>> sdml = SDML_Supervised(num_constraints=200) + >>> sdml = SDML_Supervised(n_constraints=200) >>> sdml.fit(X, Y) References @@ -262,7 +262,7 @@ class SDML_Supervised(_BaseSDML, TransformerMixin): (n_features, n_features), that will be used as such to set the prior. - num_constraints : int, optional (default=None) + n_constraints : int, optional (default=None) Number of constraints to generate. If None, defaults to `20 * num_classes**2`. @@ -279,6 +279,8 @@ class SDML_Supervised(_BaseSDML, TransformerMixin): prior. In any case, `random_state` is also used to randomly sample constraints from labels. + num_constraints : Renamed to n_constraints. Will be deprecated in 0.7.0 + Attributes ---------- components_ : `numpy.ndarray`, shape=(n_features, n_features) @@ -293,13 +295,22 @@ class SDML_Supervised(_BaseSDML, TransformerMixin): """ def __init__(self, balance_param=0.5, sparsity_param=0.01, prior='identity', - num_constraints=None, verbose=False, preprocessor=None, - random_state=None): + n_constraints=None, verbose=False, preprocessor=None, + random_state=None, num_constraints='deprecated'): _BaseSDML.__init__(self, balance_param=balance_param, sparsity_param=sparsity_param, prior=prior, verbose=verbose, preprocessor=preprocessor, random_state=random_state) - self.num_constraints = num_constraints + if num_constraints != 'deprecated': + warnings.warn('"num_constraints" parameter has been renamed to' + ' "n_constraints". It has been deprecated in' + ' version 0.6.3 and will be removed in 0.7.0' + '', FutureWarning) + self.n_constraints = num_constraints + else: + self.n_constraints = n_constraints + # Avoid test get_params from failing (all params passed sholud be set) + self.num_constraints = 'deprecated' def fit(self, X, y): """Create constraints from labels and learn the SDML model. @@ -318,13 +329,13 @@ def fit(self, X, y): Returns the instance. """ X, y = self._prepare_inputs(X, y, ensure_min_samples=2) - num_constraints = self.num_constraints - if num_constraints is None: + n_constraints = self.n_constraints + if n_constraints is None: num_classes = len(np.unique(y)) - num_constraints = 20 * num_classes**2 + n_constraints = 20 * num_classes**2 c = Constraints(y) - pos_neg = c.positive_negative_pairs(num_constraints, + pos_neg = c.positive_negative_pairs(n_constraints, random_state=self.random_state) pairs, y = wrap_pairs(X, pos_neg) return _BaseSDML._fit(self, pairs, y) diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index fe1560c2..a39c7b3c 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -326,7 +326,7 @@ def test_large_output_iter(self): class TestLSML(MetricTestCase): def test_iris(self): - lsml = LSML_Supervised(num_constraints=200) + lsml = LSML_Supervised(n_constraints=200) lsml.fit(self.iris_points, self.iris_labels) csep = class_separation(lsml.transform(self.iris_points), self.iris_labels) @@ -335,7 +335,7 @@ def test_iris(self): class TestITML(MetricTestCase): def test_iris(self): - itml = ITML_Supervised(num_constraints=200) + itml = ITML_Supervised(n_constraints=200) itml.fit(self.iris_points, self.iris_labels) csep = class_separation(itml.transform(self.iris_points), self.iris_labels) @@ -381,7 +381,7 @@ def test_bounds_parameters_invalid(bounds): class TestLMNN(MetricTestCase): def test_iris(self): - lmnn = LMNN(k=5, learn_rate=1e-6, verbose=False) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False) lmnn.fit(self.iris_points, self.iris_labels) csep = class_separation(lmnn.transform(self.iris_points), @@ -398,7 +398,7 @@ def test_loss_grad_lbfgs(self): L = rng.randn(rng.randint(1, X.shape[1] + 1), X.shape[1]) lmnn = LMNN() - k = lmnn.k + k = lmnn.n_neighbors reg = lmnn.regularization X, y = lmnn._prepare_inputs(X, y, dtype=float, @@ -574,9 +574,9 @@ def _loss_grad(self, X, L, dfG, k, reg, target_neighbors, label_inds): def test_toy_ex_lmnn(X, y, loss): """Test that the loss give the right result on a toy example""" L = np.array([[1]]) - lmnn = LMNN(k=1, regularization=0.5) + lmnn = LMNN(n_neighbors=1, regularization=0.5) - k = lmnn.k + k = lmnn.n_neighbors reg = lmnn.regularization X, y = lmnn._prepare_inputs(X, y, dtype=float, @@ -750,7 +750,7 @@ def test_iris(self): # TODO: un-flake it! rs = np.random.RandomState(5555) - sdml = SDML_Supervised(num_constraints=1500, prior='identity', + sdml = SDML_Supervised(n_constraints=1500, prior='identity', balance_param=5e-5, random_state=rs) sdml.fit(self.iris_points, self.iris_labels) csep = class_separation(sdml.transform(self.iris_points), @@ -979,7 +979,7 @@ def test_iris(self): class TestRCA(MetricTestCase): def test_iris(self): - rca = RCA_Supervised(n_components=2, num_chunks=30, chunk_size=2) + rca = RCA_Supervised(n_components=2, n_chunks=30, chunk_size=2) rca.fit(self.iris_points, self.iris_labels) csep = class_separation(rca.transform(self.iris_points), self.iris_labels) self.assertLess(csep, 0.29) @@ -1005,15 +1005,15 @@ def test_rank_deficient_returns_warning(self): def test_unknown_labels(self): n = 200 - num_chunks = 50 + n_chunks = 50 X, y = make_classification(random_state=42, n_samples=2 * n, n_features=6, n_informative=6, n_redundant=0) y2 = np.concatenate((y[:n], -np.ones(n))) - rca = RCA_Supervised(num_chunks=num_chunks, random_state=42) + rca = RCA_Supervised(n_chunks=n_chunks, random_state=42) rca.fit(X[:n], y[:n]) - rca2 = RCA_Supervised(num_chunks=num_chunks, random_state=42) + rca2 = RCA_Supervised(n_chunks=n_chunks, random_state=42) rca2.fit(X, y2) assert not np.any(np.isnan(rca.components_)) @@ -1023,11 +1023,11 @@ def test_unknown_labels(self): def test_bad_parameters(self): n = 200 - num_chunks = 3 + n_chunks = 3 X, y = make_classification(random_state=42, n_samples=n, n_features=6, n_informative=6, n_redundant=0) - rca = RCA_Supervised(num_chunks=num_chunks, random_state=42) + rca = RCA_Supervised(n_chunks=n_chunks, random_state=42) msg = ('Due to the parameters of RCA_Supervised, ' 'the inner covariance matrix is not invertible, ' 'so the transformation matrix will contain Nan values. ' @@ -1081,7 +1081,7 @@ def test_iris(self): # Full metric n_features = self.iris_points.shape[1] - mmc = MMC(convergence_threshold=0.01, init=np.eye(n_features) / 10) + mmc = MMC(tol=0.01, init=np.eye(n_features) / 10) mmc.fit(*wrap_pairs(self.iris_points, [a, b, c, d])) expected = [[+0.000514, +0.000868, -0.001195, -0.001703], [+0.000868, +0.001468, -0.002021, -0.002879], diff --git a/test/test_base_metric.py b/test/test_base_metric.py index baa585b9..fa641526 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -37,15 +37,16 @@ def test_covariance(self): remove_spaces(f"Covariance({merged_kwargs})")) def test_lmnn(self): - def_kwargs = {'convergence_tol': 0.001, 'init': 'auto', 'k': 3, + def_kwargs = {'convergence_tol': 0.001, 'init': 'auto', 'n_neighbors': 3, 'learn_rate': 1e-07, 'max_iter': 1000, 'min_iter': 50, 'n_components': None, 'preprocessor': None, 'random_state': None, 'regularization': 0.5, 'verbose': False} - nndef_kwargs = {'convergence_tol': 0.01, 'k': 6} + nndef_kwargs = {'convergence_tol': 0.01, 'n_neighbors': 6} merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( - remove_spaces(str(metric_learn.LMNN(convergence_tol=0.01, k=6))), + remove_spaces(str(metric_learn.LMNN(convergence_tol=0.01, + n_neighbors=6))), remove_spaces(f"LMNN({merged_kwargs})")) def test_nca(self): @@ -66,21 +67,21 @@ def test_lfda(self): remove_spaces(f"LFDA({merged_kwargs})")) def test_itml(self): - def_kwargs = {'convergence_threshold': 0.001, 'gamma': 1.0, + def_kwargs = {'tol': 0.001, 'gamma': 1.0, 'max_iter': 1000, 'preprocessor': None, 'prior': 'identity', 'random_state': None, 'verbose': False} nndef_kwargs = {'gamma': 0.5} merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.ITML(gamma=0.5))), remove_spaces(f"ITML({merged_kwargs})")) - def_kwargs = {'convergence_threshold': 0.001, 'gamma': 1.0, - 'max_iter': 1000, 'num_constraints': None, + def_kwargs = {'tol': 0.001, 'gamma': 1.0, + 'max_iter': 1000, 'n_constraints': None, 'preprocessor': None, 'prior': 'identity', 'random_state': None, 'verbose': False} - nndef_kwargs = {'num_constraints': 7} + nndef_kwargs = {'n_constraints': 7} merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( - remove_spaces(str(metric_learn.ITML_Supervised(num_constraints=7))), + remove_spaces(str(metric_learn.ITML_Supervised(n_constraints=7))), remove_spaces(f"ITML_Supervised({merged_kwargs})")) def test_lsml(self): @@ -90,7 +91,7 @@ def test_lsml(self): merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.LSML(tol=0.1))), remove_spaces(f"LSML({merged_kwargs})")) - def_kwargs = {'max_iter': 1000, 'num_constraints': None, + def_kwargs = {'max_iter': 1000, 'n_constraints': None, 'preprocessor': None, 'prior': 'identity', 'random_state': None, 'tol': 0.001, 'verbose': False, 'weights': None} @@ -108,7 +109,7 @@ def test_sdml(self): merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.SDML(verbose=True))), remove_spaces(f"SDML({merged_kwargs})")) - def_kwargs = {'balance_param': 0.5, 'num_constraints': None, + def_kwargs = {'balance_param': 0.5, 'n_constraints': None, 'preprocessor': None, 'prior': 'identity', 'random_state': None, 'sparsity_param': 0.01, 'verbose': False} @@ -124,12 +125,12 @@ def test_rca(self): merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.RCA(n_components=3))), remove_spaces(f"RCA({merged_kwargs})")) - def_kwargs = {'chunk_size': 2, 'n_components': None, 'num_chunks': 100, + def_kwargs = {'chunk_size': 2, 'n_components': None, 'n_chunks': 100, 'preprocessor': None, 'random_state': None} - nndef_kwargs = {'num_chunks': 5} + nndef_kwargs = {'n_chunks': 5} merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual( - remove_spaces(str(metric_learn.RCA_Supervised(num_chunks=5))), + remove_spaces(str(metric_learn.RCA_Supervised(n_chunks=5))), remove_spaces(f"RCA_Supervised({merged_kwargs})")) def test_mlkr(self): @@ -142,7 +143,7 @@ def test_mlkr(self): remove_spaces(f"MLKR({merged_kwargs})")) def test_mmc(self): - def_kwargs = {'convergence_threshold': 0.001, 'diagonal': False, + def_kwargs = {'tol': 0.001, 'diagonal': False, 'diagonal_c': 1.0, 'init': 'identity', 'max_iter': 100, 'max_proj': 10000, 'preprocessor': None, 'random_state': None, 'verbose': False} @@ -150,9 +151,9 @@ def test_mmc(self): merged_kwargs = sk_repr_kwargs(def_kwargs, nndef_kwargs) self.assertEqual(remove_spaces(str(metric_learn.MMC(diagonal=True))), remove_spaces(f"MMC({merged_kwargs})")) - def_kwargs = {'convergence_threshold': 1e-06, 'diagonal': False, + def_kwargs = {'tol': 1e-06, 'diagonal': False, 'diagonal_c': 1.0, 'init': 'identity', 'max_iter': 100, - 'max_proj': 10000, 'num_constraints': None, + 'max_proj': 10000, 'n_constraints': None, 'preprocessor': None, 'random_state': None, 'verbose': False} nndef_kwargs = {'max_iter': 1} diff --git a/test/test_components_metric_conversion.py b/test/test_components_metric_conversion.py index 5502ad90..c6113957 100644 --- a/test/test_components_metric_conversion.py +++ b/test/test_components_metric_conversion.py @@ -29,27 +29,27 @@ def test_cov(self): def test_lsml_supervised(self): seed = np.random.RandomState(1234) - lsml = LSML_Supervised(num_constraints=200, random_state=seed) + lsml = LSML_Supervised(n_constraints=200, random_state=seed) lsml.fit(self.X, self.y) L = lsml.components_ assert_array_almost_equal(L.T.dot(L), lsml.get_mahalanobis_matrix()) def test_itml_supervised(self): seed = np.random.RandomState(1234) - itml = ITML_Supervised(num_constraints=200, random_state=seed) + itml = ITML_Supervised(n_constraints=200, random_state=seed) itml.fit(self.X, self.y) L = itml.components_ assert_array_almost_equal(L.T.dot(L), itml.get_mahalanobis_matrix()) def test_lmnn(self): - lmnn = LMNN(k=5, learn_rate=1e-6, verbose=False) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False) lmnn.fit(self.X, self.y) L = lmnn.components_ assert_array_almost_equal(L.T.dot(L), lmnn.get_mahalanobis_matrix()) def test_sdml_supervised(self): seed = np.random.RandomState(1234) - sdml = SDML_Supervised(num_constraints=1500, prior='identity', + sdml = SDML_Supervised(n_constraints=1500, prior='identity', balance_param=1e-5, random_state=seed) sdml.fit(self.X, self.y) L = sdml.components_ @@ -69,7 +69,7 @@ def test_lfda(self): assert_array_almost_equal(L.T.dot(L), lfda.get_mahalanobis_matrix()) def test_rca_supervised(self): - rca = RCA_Supervised(n_components=2, num_chunks=30, chunk_size=2) + rca = RCA_Supervised(n_components=2, n_chunks=30, chunk_size=2) rca.fit(self.X, self.y) L = rca.components_ assert_array_almost_equal(L.T.dot(L), rca.get_mahalanobis_matrix()) diff --git a/test/test_constraints.py b/test/test_constraints.py index def228d4..3429d9cc 100644 --- a/test/test_constraints.py +++ b/test/test_constraints.py @@ -7,14 +7,14 @@ SEED = 42 -def gen_labels_for_chunks(num_chunks, chunk_size, +def gen_labels_for_chunks(n_chunks, chunk_size, n_classes=10, n_unknown_labels=5): - """Generates num_chunks*chunk_size labels that split in num_chunks chunks, + """Generates n_chunks*chunk_size labels that split in n_chunks chunks, that are homogeneous in the label.""" - assert min(num_chunks, chunk_size) > 0 + assert min(n_chunks, chunk_size) > 0 classes = shuffle(np.arange(n_classes), random_state=SEED) - n_per_class = chunk_size * (num_chunks // n_classes) - n_maj_class = chunk_size * num_chunks - n_per_class * (n_classes - 1) + n_per_class = chunk_size * (n_chunks // n_classes) + n_maj_class = chunk_size * n_chunks - n_per_class * (n_classes - 1) first_labels = classes[0] * np.ones(n_maj_class, dtype=int) remaining_labels = np.concatenate([k * np.ones(n_per_class, dtype=int) @@ -25,48 +25,48 @@ def gen_labels_for_chunks(num_chunks, chunk_size, return shuffle(labels, random_state=SEED) -@pytest.mark.parametrize("num_chunks, chunk_size", [(5, 10), (10, 50)]) -def test_exact_num_points_for_chunks(num_chunks, chunk_size): +@pytest.mark.parametrize("n_chunks, chunk_size", [(5, 10), (10, 50)]) +def test_exact_num_points_for_chunks(n_chunks, chunk_size): """Checks that the chunk generation works well with just enough points.""" - labels = gen_labels_for_chunks(num_chunks, chunk_size) + labels = gen_labels_for_chunks(n_chunks, chunk_size) constraints = Constraints(labels) - chunks = constraints.chunks(num_chunks=num_chunks, chunk_size=chunk_size, + chunks = constraints.chunks(n_chunks=n_chunks, chunk_size=chunk_size, random_state=SEED) chunk_no, size_each_chunk = np.unique(chunks[chunks >= 0], return_counts=True) np.testing.assert_array_equal(size_each_chunk, chunk_size) - assert chunk_no.shape[0] == num_chunks + assert chunk_no.shape[0] == n_chunks -@pytest.mark.parametrize("num_chunks, chunk_size", [(5, 10), (10, 50)]) -def test_chunk_case_one_miss_point(num_chunks, chunk_size): +@pytest.mark.parametrize("n_chunks, chunk_size", [(5, 10), (10, 50)]) +def test_chunk_case_one_miss_point(n_chunks, chunk_size): """Checks that the chunk generation breaks when one point is missing.""" - labels = gen_labels_for_chunks(num_chunks, chunk_size) + labels = gen_labels_for_chunks(n_chunks, chunk_size) assert len(labels) >= 1 constraints = Constraints(labels[1:]) with pytest.raises(ValueError) as e: - constraints.chunks(num_chunks=num_chunks, chunk_size=chunk_size, + constraints.chunks(n_chunks=n_chunks, chunk_size=chunk_size, random_state=SEED) expected_message = (('Not enough possible chunks of %d elements in each' ' class to form expected %d chunks - maximum number' ' of chunks is %d' - ) % (chunk_size, num_chunks, num_chunks - 1)) + ) % (chunk_size, n_chunks, n_chunks - 1)) assert str(e.value) == expected_message -@pytest.mark.parametrize("num_chunks, chunk_size", [(5, 10), (10, 50)]) -def test_unknown_labels_not_in_chunks(num_chunks, chunk_size): +@pytest.mark.parametrize("n_chunks, chunk_size", [(5, 10), (10, 50)]) +def test_unknown_labels_not_in_chunks(n_chunks, chunk_size): """Checks that unknown labels are not assigned to any chunk.""" - labels = gen_labels_for_chunks(num_chunks, chunk_size) + labels = gen_labels_for_chunks(n_chunks, chunk_size) constraints = Constraints(labels) - chunks = constraints.chunks(num_chunks=num_chunks, chunk_size=chunk_size, + chunks = constraints.chunks(n_chunks=n_chunks, chunk_size=chunk_size, random_state=SEED) assert np.all(chunks[labels < 0] < 0) diff --git a/test/test_fit_transform.py b/test/test_fit_transform.py index d4d4bfe0..246223b0 100644 --- a/test/test_fit_transform.py +++ b/test/test_fit_transform.py @@ -29,47 +29,47 @@ def test_cov(self): def test_lsml_supervised(self): seed = np.random.RandomState(1234) - lsml = LSML_Supervised(num_constraints=200, random_state=seed) + lsml = LSML_Supervised(n_constraints=200, random_state=seed) lsml.fit(self.X, self.y) res_1 = lsml.transform(self.X) seed = np.random.RandomState(1234) - lsml = LSML_Supervised(num_constraints=200, random_state=seed) + lsml = LSML_Supervised(n_constraints=200, random_state=seed) res_2 = lsml.fit_transform(self.X, self.y) assert_array_almost_equal(res_1, res_2) def test_itml_supervised(self): seed = np.random.RandomState(1234) - itml = ITML_Supervised(num_constraints=200, random_state=seed) + itml = ITML_Supervised(n_constraints=200, random_state=seed) itml.fit(self.X, self.y) res_1 = itml.transform(self.X) seed = np.random.RandomState(1234) - itml = ITML_Supervised(num_constraints=200, random_state=seed) + itml = ITML_Supervised(n_constraints=200, random_state=seed) res_2 = itml.fit_transform(self.X, self.y) assert_array_almost_equal(res_1, res_2) def test_lmnn(self): - lmnn = LMNN(k=5, learn_rate=1e-6, verbose=False) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False) lmnn.fit(self.X, self.y) res_1 = lmnn.transform(self.X) - lmnn = LMNN(k=5, learn_rate=1e-6, verbose=False) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False) res_2 = lmnn.fit_transform(self.X, self.y) assert_array_almost_equal(res_1, res_2) def test_sdml_supervised(self): seed = np.random.RandomState(1234) - sdml = SDML_Supervised(num_constraints=1500, balance_param=1e-5, + sdml = SDML_Supervised(n_constraints=1500, balance_param=1e-5, prior='identity', random_state=seed) sdml.fit(self.X, self.y) res_1 = sdml.transform(self.X) seed = np.random.RandomState(1234) - sdml = SDML_Supervised(num_constraints=1500, balance_param=1e-5, + sdml = SDML_Supervised(n_constraints=1500, balance_param=1e-5, prior='identity', random_state=seed) res_2 = sdml.fit_transform(self.X, self.y) @@ -99,13 +99,13 @@ def test_lfda(self): def test_rca_supervised(self): seed = np.random.RandomState(1234) - rca = RCA_Supervised(n_components=2, num_chunks=30, chunk_size=2, + rca = RCA_Supervised(n_components=2, n_chunks=30, chunk_size=2, random_state=seed) rca.fit(self.X, self.y) res_1 = rca.transform(self.X) seed = np.random.RandomState(1234) - rca = RCA_Supervised(n_components=2, num_chunks=30, chunk_size=2, + rca = RCA_Supervised(n_components=2, n_chunks=30, chunk_size=2, random_state=seed) res_2 = rca.fit_transform(self.X, self.y) @@ -123,12 +123,12 @@ def test_mlkr(self): def test_mmc_supervised(self): seed = np.random.RandomState(1234) - mmc = MMC_Supervised(num_constraints=200, random_state=seed) + mmc = MMC_Supervised(n_constraints=200, random_state=seed) mmc.fit(self.X, self.y) res_1 = mmc.transform(self.X) seed = np.random.RandomState(1234) - mmc = MMC_Supervised(num_constraints=200, random_state=seed) + mmc = MMC_Supervised(n_constraints=200, random_state=seed) res_2 = mmc.fit_transform(self.X, self.y) assert_array_almost_equal(res_1, res_2) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index e69aa032..b5dbc248 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -438,7 +438,7 @@ def test_auto_init_transformation(n_samples, n_features, n_classes, random_state=rng) # To make the test work for LMNN: if 'LMNN' in model_base.__class__.__name__: - model_base.set_params(k=1) + model_base.set_params(n_neighbors=1) # To make the test faster for estimators that have a max_iter: if hasattr(model_base, 'max_iter'): model_base.set_params(max_iter=1) diff --git a/test/test_sklearn_compat.py b/test/test_sklearn_compat.py index d2369b1c..798d9036 100644 --- a/test/test_sklearn_compat.py +++ b/test/test_sklearn_compat.py @@ -29,7 +29,7 @@ def __init__(self, n_components=None, chunk_size=2, preprocessor=None, random_state=None): # this init makes RCA stable for scikit-learn examples. super(Stable_RCA_Supervised, self).__init__( - num_chunks=2, n_components=n_components, + n_chunks=2, n_components=n_components, chunk_size=chunk_size, preprocessor=preprocessor, random_state=random_state) @@ -37,12 +37,12 @@ def __init__(self, n_components=None, class Stable_SDML_Supervised(SDML_Supervised): def __init__(self, sparsity_param=0.01, - num_constraints=None, verbose=False, preprocessor=None, + n_constraints=None, verbose=False, preprocessor=None, random_state=None): # this init makes SDML stable for scikit-learn examples. super(Stable_SDML_Supervised, self).__init__( sparsity_param=sparsity_param, - num_constraints=num_constraints, verbose=verbose, + n_constraints=n_constraints, verbose=verbose, preprocessor=preprocessor, balance_param=1e-5, prior='identity', random_state=random_state) diff --git a/test/test_utils.py b/test/test_utils.py index f3000344..43d67111 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -60,11 +60,11 @@ def build_regression(with_preprocessor=False): def build_data(): input_data, labels = load_iris(return_X_y=True) X, y = shuffle(input_data, labels, random_state=SEED) - num_constraints = 50 + n_constraints = 50 constraints = Constraints(y) pairs = ( constraints - .positive_negative_pairs(num_constraints, same_length=True, + .positive_negative_pairs(n_constraints, same_length=True, random_state=check_random_state(SEED))) return X, pairs @@ -137,7 +137,7 @@ def build_quadruplets(with_preprocessor=False): (ITML_Supervised(max_iter=5), build_classification), (LSML_Supervised(), build_classification), (MMC_Supervised(max_iter=5), build_classification), - (RCA_Supervised(num_chunks=5), build_classification), + (RCA_Supervised(n_chunks=5), build_classification), (SDML_Supervised(prior='identity', balance_param=1e-5), build_classification), (SCML_Supervised(n_basis=80), build_classification)] From 4a6caeac69c4fe1e6124f171f15452dec7e768b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bellet?= Date: Tue, 21 Jun 2022 13:53:05 +0200 Subject: [PATCH 29/40] fix docstring (#351) --- metric_learn/scml.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index db2fdf64..63d64cfb 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -328,9 +328,10 @@ class SCML(_BaseSCML, _TripletsClassifierMixin): 'triplet_diffs', and an array-like of shape (n_basis, n_features). 'triplet_diffs' - The basis set is constructed from the differences between points of - `n_basis` positive or negative pairs taken from the triplets - constrains. + The basis set is constructed iteratively from differences between points + of `n_features` positive or negative pairs randomly sampled from the + triplets constraints. Requires the number of training triplets to be + great or equal to `n_features`. array-like A matrix of shape (n_basis, n_features), that will be used as From 0acf8b1ec746809dfeef2207a6f29b41de122a0f Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:03:44 -0400 Subject: [PATCH 30/40] Remove deprecated assert_warns_message import This was deprecated and is now removed from sklearn. Thankfully, we don't appear to be using it. --- metric_learn/sklearn_shims.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/metric_learn/sklearn_shims.py b/metric_learn/sklearn_shims.py index 654a9144..8d746890 100644 --- a/metric_learn/sklearn_shims.py +++ b/metric_learn/sklearn_shims.py @@ -6,7 +6,6 @@ >= version.parse('0.22.0')) if SKLEARN_AT_LEAST_0_22: from sklearn.utils._testing import (set_random_state, - assert_warns_message, ignore_warnings, assert_allclose_dense_sparse, _get_args) @@ -15,13 +14,12 @@ from sklearn.metrics._scorer import get_scorer else: from sklearn.utils.testing import (set_random_state, - assert_warns_message, ignore_warnings, assert_allclose_dense_sparse, _get_args) from sklearn.utils.estimator_checks import is_public_parameter from sklearn.metrics.scorer import get_scorer -__all__ = ['set_random_state', 'assert_warns_message', 'set_random_state', +__all__ = ['set_random_state', 'set_random_state', 'ignore_warnings', 'assert_allclose_dense_sparse', '_get_args', 'is_public_parameter', 'get_scorer'] From 084e1a9412e09b8a77a6cedb8e1f764b58c041cc Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:07:54 -0400 Subject: [PATCH 31/40] Update Python versions to test --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aaec0736..1fe061e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 - name: Set up Python From 8d5059c63de2f52b31dc0a534931d4a1f09da895 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:09:45 -0400 Subject: [PATCH 32/40] Fix minor lint issues --- metric_learn/scml.py | 7 ++----- test/metric_learn_test.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index 63d64cfb..c3a332f6 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -53,7 +53,7 @@ def _fit(self, triplets, basis=None, n_basis=None): raise ValueError("batch_size should be an integer, instead it is of type" " %s" % type(self.batch_size)) - if(self.output_iter > self.max_iter): + if self.output_iter > self.max_iter: raise ValueError("The value of output_iter must be equal or smaller than" " max_iter.") @@ -266,11 +266,8 @@ def _generate_bases_dist_diff(self, triplets, X): start = 0 finish = 0 - - while(finish != n_basis): - + while finish != n_basis: # Select triplets to yield diff - select_triplet = rng.choice(n_triplets, size=n_features, replace=False) # select n_features positive differences diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index a39c7b3c..f109a667 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -14,7 +14,7 @@ from sklearn.preprocessing import StandardScaler try: from inverse_covariance import quic - assert(quic) + assert quic except ImportError: HAS_SKGGM = False else: From 7102bc5a8d0db499c7732e5f9031cf000a538298 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:20:32 -0400 Subject: [PATCH 33/40] Resolve some SCML issues The sklearn KMeans class now warns if you don't provide a value for the n_init parameter. I'm setting it to the original default, but we may want to consider setting it to 'auto' in the future. --- metric_learn/scml.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/metric_learn/scml.py b/metric_learn/scml.py index c3a332f6..fedf393d 100644 --- a/metric_learn/scml.py +++ b/metric_learn/scml.py @@ -558,7 +558,7 @@ def _initialize_basis_supervised(self, X, y): case one is selected. """ - if self.basis == 'lda': + if isinstance(self.basis, str) and self.basis == 'lda': basis, n_basis = self._generate_bases_LDA(X, y) else: basis, n_basis = None, None @@ -606,8 +606,8 @@ def _generate_bases_LDA(self, X, y): "should be smaller than %d" % (n_basis, X.shape[0]*2*num_eig)) - kmeans = KMeans(n_clusters=n_clusters, random_state=self.random_state, - algorithm='elkan').fit(X) + kmeans = KMeans(n_clusters=n_clusters, n_init=10, + random_state=self.random_state, algorithm='elkan').fit(X) cX = kmeans.cluster_centers_ n_scales = 2 From 81901ee39de7abb5dfcfb631967a6c7ef3e8f735 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:27:38 -0400 Subject: [PATCH 34/40] Fix mahalanobis test --- test/test_mahalanobis_mixin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index b5dbc248..1e94065d 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -215,8 +215,7 @@ def test_get_metric_equivalent_to_explicit_mahalanobis(estimator, metric = model.get_metric() n_features = X.shape[1] a, b = (rng.randn(n_features), rng.randn(n_features)) - expected_dist = mahalanobis(a[None], b[None], - VI=model.get_mahalanobis_matrix()) + expected_dist = mahalanobis(a, b, VI=model.get_mahalanobis_matrix()) assert_allclose(metric(a, b), expected_dist, rtol=1e-13) From 053779ad6351752dc0d92c63622ef5e8eaf17bc9 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:35:47 -0400 Subject: [PATCH 35/40] Skip single-feature test for SDML --- test/test_mahalanobis_mixin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_mahalanobis_mixin.py b/test/test_mahalanobis_mixin.py index 1e94065d..9378ac60 100644 --- a/test/test_mahalanobis_mixin.py +++ b/test/test_mahalanobis_mixin.py @@ -15,6 +15,7 @@ from metric_learn.sklearn_shims import set_random_state from metric_learn._util import make_context, _initialize_metric_mahalanobis +from metric_learn.sdml import _BaseSDML from metric_learn.base_metric import (_QuadrupletsClassifierMixin, _TripletsClassifierMixin, _PairsClassifierMixin) @@ -290,7 +291,11 @@ def test_components_is_2D(estimator, build_dataset): model.fit(*remove_y(estimator, input_data, labels)) assert model.components_.shape == (X.shape[1], X.shape[1]) - # test that it works for 1 feature. Use 2nd dimention, to avoid border cases + if isinstance(estimator, _BaseSDML): + # SDML doesn't support running on a single feature. + return + + # test that it works for 1 feature. Use 2nd dimension, to avoid border cases trunc_data = input_data[..., 1:2] # we drop duplicates that might have been formed, i.e. of the form # aabc or abcc or aabb for quadruplets, and aa for pairs. From 4e89e3de31e98425f84c3c6a424bba9f25745e74 Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 09:38:39 -0400 Subject: [PATCH 36/40] Drop old Python versions from the test matrix --- .github/workflows/main.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1fe061e9..0935a109 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,32 +9,6 @@ on: branches: [ master ] jobs: - - # Checks compatibility with an old version of sklearn (0.21.3) - compatibility: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - python-version: ['3.6', '3.7'] - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Run Tests with skggm + scikit-learn 0.21.3 - env: - SKGGM_VERSION: a0ed406586c4364ea3297a658f415e13b5cbdaf8 - run: | - sudo apt-get install liblapack-dev - pip install --upgrade pip pytest - pip install wheel cython numpy scipy codecov pytest-cov - pip install scikit-learn==0.21.3 - pip install git+https://github.com/skggm/skggm.git@${SKGGM_VERSION} - pytest test --cov - bash <(curl -s https://codecov.io/bash) - # Run normal testing with the latest versions of all dependencies build: runs-on: ${{ matrix.os }} From e5b06fa208e33388feead20831130ea6859d67bd Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 14:21:14 -0400 Subject: [PATCH 37/40] Fix sklearn compat issues --- metric_learn/base_metric.py | 11 +++++++---- metric_learn/sdml.py | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/metric_learn/base_metric.py b/metric_learn/base_metric.py index d0ba1ef9..47efe4b7 100644 --- a/metric_learn/base_metric.py +++ b/metric_learn/base_metric.py @@ -2,7 +2,7 @@ Base module. """ -from sklearn.base import BaseEstimator +from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils.extmath import stable_cumsum from sklearn.utils.validation import _is_arraylike, check_is_fitted from sklearn.metrics import roc_auc_score, roc_curve, precision_recall_curve @@ -464,7 +464,7 @@ def get_mahalanobis_matrix(self): return self.components_.T.dot(self.components_) -class _PairsClassifierMixin(BaseMetricLearner): +class _PairsClassifierMixin(BaseMetricLearner, ClassifierMixin): """Base class for pairs learners. Attributes @@ -475,6 +475,7 @@ class _PairsClassifierMixin(BaseMetricLearner): classified as dissimilar. """ + classes_ = np.array([0, 1]) _tuple_size = 2 # number of points in a tuple, 2 for pairs def predict(self, pairs): @@ -752,11 +753,12 @@ def _validate_calibration_params(strategy='accuracy', min_rate=None, 'Got {} instead.'.format(type(beta))) -class _TripletsClassifierMixin(BaseMetricLearner): +class _TripletsClassifierMixin(BaseMetricLearner, ClassifierMixin): """ Base class for triplets learners. """ + classes_ = np.array([0, 1]) _tuple_size = 3 # number of points in a tuple, 3 for triplets def predict(self, triplets): @@ -837,11 +839,12 @@ def score(self, triplets): return self.predict(triplets).mean() / 2 + 0.5 -class _QuadrupletsClassifierMixin(BaseMetricLearner): +class _QuadrupletsClassifierMixin(BaseMetricLearner, ClassifierMixin): """ Base class for quadruplets learners. """ + classes_ = np.array([0, 1]) _tuple_size = 4 # number of points in a tuple, 4 for quadruplets def predict(self, quadruplets): diff --git a/metric_learn/sdml.py b/metric_learn/sdml.py index 93f3f441..c76de99b 100644 --- a/metric_learn/sdml.py +++ b/metric_learn/sdml.py @@ -43,6 +43,9 @@ def _fit(self, pairs, y): print("SDML will use skggm's graphical lasso solver.") pairs, y = self._prepare_inputs(pairs, y, type_of_inputs='tuples') + n_features = pairs.shape[2] + if n_features < 2: + raise ValueError(f"Cannot fit SDML with {n_features} feature(s)") # set up (the inverse of) the prior M # if the prior is the default (None), we raise a warning @@ -83,6 +86,7 @@ def _fit(self, pairs, y): w_mahalanobis, _ = np.linalg.eigh(M) not_spd = any(w_mahalanobis < 0.) not_finite = not np.isfinite(M).all() + # TODO: Narrow this to the specific exceptions we expect. except Exception as e: raised_error = e not_spd = False # not_spd not applicable here so we set to False From b3044a837bd8b32c7a9c7189970a48046d48a11e Mon Sep 17 00:00:00 2001 From: CJ Carey Date: Thu, 28 Sep 2023 20:13:39 -0400 Subject: [PATCH 38/40] Bump version to 0.7.0 --- doc/conf.py | 6 +++--- metric_learn/_version.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 5eb312dc..c472cc21 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,12 +21,12 @@ # General information about the project. project = u'metric-learn' -copyright = (u'2015-2020, CJ Carey, Yuan Tang, William de Vazelhes, Aurélien ' +copyright = (u'2015-2023, CJ Carey, Yuan Tang, William de Vazelhes, Aurélien ' u'Bellet and Nathalie Vauquier') author = (u'CJ Carey, Yuan Tang, William de Vazelhes, Aurélien Bellet and ' u'Nathalie Vauquier') -version = '0.6.2' -release = '0.6.2' +version = '0.7.0' +release = '0.7.0' language = 'en' exclude_patterns = ['_build'] diff --git a/metric_learn/_version.py b/metric_learn/_version.py index aece342d..a71c5c7f 100644 --- a/metric_learn/_version.py +++ b/metric_learn/_version.py @@ -1 +1 @@ -__version__ = '0.6.2' +__version__ = '0.7.0' From 8fb6872edc5ee4a2751386e2058730fe55449488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damin=20K=C3=BChn?= Date: Fri, 2 Aug 2024 03:25:56 +0200 Subject: [PATCH 39/40] Update supervised.rst (#356) The verbose (and almost all other parameters) need to be passed to the constructor and not the .fit() function. This was not refelcted in the example snipplets for LMNN in the docs. --- doc/supervised.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/supervised.rst b/doc/supervised.rst index a847a33c..49548b83 100644 --- a/doc/supervised.rst +++ b/doc/supervised.rst @@ -164,8 +164,8 @@ indicates :math:`\mathbf{x}_{i}, \mathbf{x}_{j}` belong to different classes, X = iris_data['data'] Y = iris_data['target'] - lmnn = LMNN(n_neighbors=5, learn_rate=1e-6) - lmnn.fit(X, Y, verbose=False) + lmnn = LMNN(n_neighbors=5, learn_rate=1e-6, verbose=False) + lmnn.fit(X, Y) .. rubric:: References From dc7e4499b1a9e522f03c87ba8dc249f9747cac82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Bellet?= Date: Sat, 3 Aug 2024 21:34:12 +0200 Subject: [PATCH 40/40] [MRG] Fix test failures due to updated packages: deprecated pytest.warns(None) syntax + GLasso update in sklearn (#357) * Fix GLasso import for SDML for newer sklearn versions * fix import and argument issue * also fix deprecated pytest.warns(None) syntex * fix flake8 --- metric_learn/sdml.py | 14 ++++++++++---- test/metric_learn_test.py | 9 +++++---- test/test_base_metric.py | 5 +++-- test/test_pairs_classifiers.py | 7 ++++--- test/test_utils.py | 19 ++++++++++--------- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/metric_learn/sdml.py b/metric_learn/sdml.py index c76de99b..c4c427b9 100644 --- a/metric_learn/sdml.py +++ b/metric_learn/sdml.py @@ -6,7 +6,13 @@ import numpy as np from sklearn.base import TransformerMixin from scipy.linalg import pinvh -from sklearn.covariance import graphical_lasso +try: + from sklearn.covariance._graph_lasso import ( + _graphical_lasso as graphical_lasso + ) +except ImportError: + from sklearn.covariance import graphical_lasso + from sklearn.exceptions import ConvergenceWarning from .base_metric import MahalanobisMixin, _PairsClassifierMixin @@ -79,9 +85,9 @@ def _fit(self, pairs, y): msg=self.verbose, Theta0=theta0, Sigma0=sigma0) else: - _, M = graphical_lasso(emp_cov, alpha=self.sparsity_param, - verbose=self.verbose, - cov_init=sigma0) + _, M, *_ = graphical_lasso(emp_cov, alpha=self.sparsity_param, + verbose=self.verbose, + cov_init=sigma0) raised_error = None w_mahalanobis, _ = np.linalg.eigh(M) not_spd = any(w_mahalanobis < 0.) diff --git a/test/metric_learn_test.py b/test/metric_learn_test.py index f109a667..d457b52d 100644 --- a/test/metric_learn_test.py +++ b/test/metric_learn_test.py @@ -1,3 +1,4 @@ +import warnings import unittest import re import pytest @@ -734,12 +735,12 @@ def test_raises_no_warning_installed_skggm(self): pairs = np.array([[[-10., 0.], [10., 0.]], [[0., -55.], [0., -60]]]) y_pairs = [1, -1] X, y = make_classification(random_state=42) - with pytest.warns(None) as records: + with warnings.catch_warnings(record=True) as records: sdml = SDML(prior='covariance') sdml.fit(pairs, y_pairs) for record in records: assert record.category is not ConvergenceWarning - with pytest.warns(None) as records: + with warnings.catch_warnings(record=True) as records: sdml_supervised = SDML_Supervised(prior='identity', balance_param=1e-5) sdml_supervised.fit(X, y) for record in records: @@ -999,7 +1000,7 @@ def test_rank_deficient_returns_warning(self): 'for instance using `sklearn.decomposition.PCA` as a ' 'preprocessing step.') - with pytest.warns(None) as raised_warnings: + with warnings.catch_warnings(record=True) as raised_warnings: rca.fit(X, y) assert any(str(w.message) == msg for w in raised_warnings) @@ -1034,7 +1035,7 @@ def test_bad_parameters(self): 'Increase the number or size of the chunks to correct ' 'this problem.' ) - with pytest.warns(None) as raised_warning: + with warnings.catch_warnings(record=True) as raised_warning: rca.fit(X, y) assert any(str(w.message) == msg for w in raised_warning) diff --git a/test/test_base_metric.py b/test/test_base_metric.py index fa641526..b1e71020 100644 --- a/test/test_base_metric.py +++ b/test/test_base_metric.py @@ -1,4 +1,5 @@ from numpy.core.numeric import array_equal +import warnings import pytest import re import unittest @@ -226,7 +227,7 @@ def test_get_metric_works_does_not_raise(estimator, build_dataset): (X[0][None], X[1][None])] for u, v in list_test_get_metric_doesnt_raise: - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: metric(u, v) assert len(record) == 0 @@ -234,7 +235,7 @@ def test_get_metric_works_does_not_raise(estimator, build_dataset): model.components_ = np.array([3.1]) metric = model.get_metric() for u, v in [(5, 6.7), ([5], [6.7]), ([[5]], [[6.7]])]: - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: metric(u, v) assert len(record) == 0 diff --git a/test/test_pairs_classifiers.py b/test/test_pairs_classifiers.py index 6a725f23..bfedefea 100644 --- a/test/test_pairs_classifiers.py +++ b/test/test_pairs_classifiers.py @@ -1,5 +1,6 @@ from functools import partial +import warnings import pytest from numpy.testing import assert_array_equal from scipy.spatial.distance import euclidean @@ -136,7 +137,7 @@ def test_threshold_different_scores_is_finite(estimator, build_dataset, estimator.set_params(preprocessor=preprocessor) set_random_state(estimator) estimator.fit(input_data, labels) - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: estimator.calibrate_threshold(input_data, labels, **kwargs) assert len(record) == 0 @@ -383,7 +384,7 @@ def test_calibrate_threshold_valid_parameters(valid_args): pairs, y = rng.randn(20, 2, 5), rng.choice([-1, 1], size=20) pairs_learner = IdentityPairsClassifier() pairs_learner.fit(pairs, y) - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: pairs_learner.calibrate_threshold(pairs, y, **valid_args) assert len(record) == 0 @@ -518,7 +519,7 @@ def test_validate_calibration_params_valid_parameters( # test that no warning message is returned if valid arguments are given to # _validate_calibration_params for all pairs metric learners, as well as # a mocking example, and the class itself - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: estimator._validate_calibration_params(**valid_args) assert len(record) == 0 diff --git a/test/test_utils.py b/test/test_utils.py index 43d67111..c0383792 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,3 +1,4 @@ +import warnings import pytest from scipy.linalg import eigh, pinvh from collections import namedtuple @@ -353,7 +354,7 @@ def test_check_tuples_valid_tuple_size(tuple_size): checks that checking the number of tuples (pairs, quadruplets, etc) raises no warning if there is the right number of points in a tuple. """ - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(tuples_prep(), type_of_inputs='tuples', preprocessor=mock_preprocessor, tuple_size=tuple_size) check_input(tuples_no_prep(), type_of_inputs='tuples', preprocessor=None, @@ -378,7 +379,7 @@ def test_check_tuples_valid_tuple_size(tuple_size): [[2.6, 2.3], [3.4, 5.0]]])]) def test_check_tuples_valid_with_preprocessor(tuples): """Test that valid inputs when using a preprocessor raises no warning""" - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(tuples, type_of_inputs='tuples', preprocessor=mock_preprocessor) assert len(record) == 0 @@ -399,7 +400,7 @@ def test_check_tuples_valid_with_preprocessor(tuples): ((3, 1), (4, 4), (29, 4)))]) def test_check_tuples_valid_without_preprocessor(tuples): """Test that valid inputs when using no preprocessor raises no warning""" - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(tuples, type_of_inputs='tuples', preprocessor=None) assert len(record) == 0 @@ -408,12 +409,12 @@ def test_check_tuples_behaviour_auto_dtype(): """Checks that check_tuples allows by default every type if using a preprocessor, and numeric types if using no preprocessor""" tuples_prep = [['img1.png', 'img2.png'], ['img3.png', 'img5.png']] - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(tuples_prep, type_of_inputs='tuples', preprocessor=mock_preprocessor) assert len(record) == 0 - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(tuples_no_prep(), type_of_inputs='tuples') # numeric type assert len(record) == 0 @@ -549,7 +550,7 @@ def test_check_classic_invalid_dtype_not_convertible(preprocessor, points): [2.6, 2.3]])]) def test_check_classic_valid_with_preprocessor(points): """Test that valid inputs when using a preprocessor raises no warning""" - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(points, type_of_inputs='classic', preprocessor=mock_preprocessor) assert len(record) == 0 @@ -570,7 +571,7 @@ def test_check_classic_valid_with_preprocessor(points): (3, 1, 4, 4, 29, 4))]) def test_check_classic_valid_without_preprocessor(points): """Test that valid inputs when using no preprocessor raises no warning""" - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(points, type_of_inputs='classic', preprocessor=None) assert len(record) == 0 @@ -585,12 +586,12 @@ def test_check_classic_behaviour_auto_dtype(): """Checks that check_input (for points) allows by default every type if using a preprocessor, and numeric types if using no preprocessor""" points_prep = ['img1.png', 'img2.png', 'img3.png', 'img5.png'] - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(points_prep, type_of_inputs='classic', preprocessor=mock_preprocessor) assert len(record) == 0 - with pytest.warns(None) as record: + with warnings.catch_warnings(record=True) as record: check_input(points_no_prep(), type_of_inputs='classic') # numeric type assert len(record) == 0